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

alisinabh
Hey everyone i’ve developed a library for Jalaali calendar for elixir which supports converting Gregorian dates to Jalaali and vice vers...
New
josevalim
Yes, yet another parser combinator library! Most of the parser combinators in the ecosystem are either compile-time, often using AST tra...
159 19870 141
New
versilov
Could not wait for the missing Elixir ML libraries to appear, so, I wrote one myself, taking https://github.com/sdwolfz/exlearn as a foun...
New
anshuman23
Hello all, I have been working on my proposed project called Tensorflex as part of Google Summer of Code 2018.. Tensorflex can be used f...
New
oltarasenko
Dear Elixir community, After a year of development, bug fixes, and improvements, we are proudly ready to share the release of Crawly 0.1...
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
restlessronin
The repo is at GitHub - cyberchitta/openai_ex: Community maintained Elixir library for OpenAI API · GitHub. Docs are at OpenaiEx User Gu...
152 10739 134
New

Other popular topics Top

electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
dogweather
I wrote this comment on r/haskell, and it’s not popular there. :wink: But I think I’m on to something… Haskell reminds me of Java, and e...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
saif
Hello everyone, Long time lurker first time poster here. I’ve recently begun working on Elixir full-time again! :raised_hands: It’s been...
New
TunkShif
This post is an instruction guide to help you setup your Neovim for Elixir development from scratch. It includes general information on h...
274 42576 114
New

We're in Beta

About us Mission Statement