andreashasse

andreashasse

Spectral - Type-driven JSON encoding/decoding, validation, and OpenAPI generation

I’m happy to announce Spectral, a library that lets your Elixir structs and @type specs become the single source of truth for validation, encoding/decoding (primarily JSON), and OpenAPI schema generation. If you’re familiar with Pydantic in the Python world, the idea is similar.

Who is this for?

Spectral is aimed at developers building and consuming JSON who want to avoid keeping multiple representations of the same information in sync — a type definition here, validation logic there, a JSON schema somewhere else. If your types already express the shape of your data, Spectral lets them do more of the work.

Example

defmodule Person do
  defstruct [:name, :age, :role]

  @type role :: :user | :admin

  @type t :: %Person{
    name: String.t(),
    age: non_neg_integer(),
    role: role()
  }

  @spec from_json(binary()) :: {:ok, t()} | {:error, [Spectral.Error.t()]}
  def from_json(json), do: Spectral.decode(json, __MODULE__, :t, :json)

  @spec to_json(t()) :: {:ok, iodata()} | {:error, [Spectral.Error.t()]}
  def to_json(person), do: Spectral.encode(person, __MODULE__, :t, :json)
end

{:ok, person} = Person.from_json(~s({"name": "Alice", "age": 30, "role": "admin"}))
#=> {:ok, %Person{name: "Alice", age: 30, role: :admin}}

Person.to_json(person)
#=> {:ok, ...}

Person.from_json(~s({"name": "Alice", "age": -1, "role": "admin"}))
#=> {:error, [%Spectral.Error{location: ["age"], type: :type_mismatch, ...}]}

# Generate OpenAPI schema
Spectral.schema(Person, :t)

:package: Hex: spectral | Hex
:open_book: Docs: Spectral v0.13.0 — Documentation

https://github.com/andreashasse/spectral

Most Liked

DaAnalyst

DaAnalyst

Maybe combine the struct type and defstruct into one (with a macro)?

It’s been a while since I developed my deftypestruct and I’ve seen someone posting a library doing something very similar here like a week or two ago.

ex (my lib creates {module_name}.t(), but can be made to use a different type name too):

defmodule Person do
  deftypestruct %{
    name: String.t(),
    age: non_neg_integer() | nil,
    role: role()
  }
end

I also check against nil so unless explicitly permitted (like the age field above) the lib raises.

mudasobwa

mudasobwa

Creator of Cure

You might be interested in taking a look at estructura allowing transparent nesting, coercion, validation, and (!) generation for stream_data property-based testing out of the box.

andreashasse

andreashasse

The deftypestruct approach is elegant. As your library generates the type and put it in the beam it works well with spectral :slight_smile:

Thanks for pointing out the nil handling in spectral, you can find more info in the spectral docs nil section.

Last Post!

andreashasse

andreashasse

Spectral update: struct defaults, field filtering, codec helpers, and a compile-time speedup

Struct defaults on decode

Missing JSON fields now fill from the struct’s own default values (__struct__/0) rather than always defaulting to nil. Non-nullable fields with a nil default still produce a missing_data error.

defmodule Job do
 use Spectral
 defstruct timeout: 30, retries: 3
 @type t :: %Job{timeout: pos_integer(), retries: non_neg_integer()}
end

Spectral.decode(~s({}), Job, :t)
#=> {:ok, %Job{timeout: 30, retries: 3}}

Field filtering

New only key in @spectral accepts a list of field atoms, restricting which fields participate in encode, decode, and schema generation. Excluded fields are filled from defaults on decode and ignored on encode. Useful for computed or internal fields you never want in the API.

defmodule User do
  use Spectral
  defstruct [:name, :email, role: :viewer]
  spectral(only: [:name, :role])
  @type t :: %User{name: String.t(), email: String.t() | nil, role: atom()}
end

Spectral.encode(%User{name: "Alice", email: "alice@example.com", role: :admin}, User, :t)
#=> {:ok, ~s({"name":"Alice","role":"admin"})}   # email excluded

Spectral.decode(~s({"name":"Alice","email":"alice@example.com"}), User, :t)
#=> {:ok, %User{name: "Alice", email: nil, role: :viewer}}  # email ignored, role filled from default

Built-in String codec

String.t() encoding and decoding is now handled by a dedicated built-in codec rather than by reading the String module’s type definitions from its BEAM file (even when cached, this is now eliminated entirely). Constraint validation (min_length, max_length, pattern, format) works as before.

Compile-time type info

Previously, use Spectral injected a spectra_type_info/0 that built type information from the module’s BEAM abstract code at runtime (cached via persistent_term). Now the type information is fully built at compile time and baked into the generated function as a constant — no runtime work at all. No API changes needed.

Where Next?

Popular in Announcing Top

bryanjos
Hi, I wanted share a small library we at Revelry Labs made for rendering react components from the server side. There are instructions fo...
New
kip
ex_cldr provides localisation and internationalisation support based upon the data from the Unicode CLDR project. Unicode released CLDR ...
407 13300 120
New
maltoe
Hello! Came here to announce ChromicPDF, a pet project PDF generator I’ve been working on for the past few months. Why another PDF gener...
New
mplatts
With HEEX released we decided to start a components library using Tailwind CSS - check it out here: Petal Components. We also have a boi...
New
scohen
Lexical Lexical is a next-generation language server for the Elixir programming language. Features Context aware code completion As-you...
New
MRdotB
I needed to reuse React components from my Chrome extension in my Phoenix/LiveView backend. I noticed that for Svelte/Vue, there are live...
New
markmark206
simple_feature_flags is a tiny package that lets you turn features on or off based on which environment (e.g. localhost, staging, product...
New

Other popular topics Top

vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
Qqwy
Original source of discussion: This topic on the Pragmatic Programmers’ Functional Web Development with Elixir, OTP, and Phoenix forum. ...
New
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" => #BSON.ObjectId<58eb1a7a9ad169198c3dXXXX>, "email" => ...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New

We're in Beta

About us Mission Statement