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)
Hex: spectral | Hex
Docs: Spectral v0.13.0 — Documentation
Most Liked
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
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
The deftypestruct approach is elegant. As your library generates the type and put it in the beam it works well with spectral ![]()
Thanks for pointing out the nil handling in spectral, you can find more info in the spectral docs nil section.
Last Post!
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.
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex









