Spectral

Spectral provides type-safe data serialization and deserialization for Elixir types. Currently the focus is on JSON.

Requirements and Installation

Requires Erlang/OTP 27+ — Spectral uses the native json module introduced in OTP 27.

Add spectral to your list of dependencies in mix.exs:

def deps do
  [
    {:spectral, "~> 0.9.2"}
  ]
end

Your modules must be compiled with debug_info for Spectral to extract type information. This is enabled by default in Mix projects.

Note: Spectral reads type information from compiled BEAM files, so modules must be defined in files (not in IEx).

Usage

Here's how to use Spectral for JSON serialization and deserialization:

# lib/person.ex
defmodule Person do
  defmodule Address do
    defstruct [:street, :city]

    @type t :: %Address{
            street: String.t(),
            city: String.t()
          }
  end

  defstruct [:name, :age, :address]

  @type t :: %Person{
          name: String.t(),
          age: non_neg_integer() | nil,
          address: Address.t() | nil
        }
end
# Encode a struct to JSON
person = %Person{
  name: "Alice",
  age: 30,
  address: %Person.Address{street: "Ystader Straße", city: "Berlin"}
}

with {:ok, json_iodata} <- Spectral.encode(person, Person, :t, :json) do
  IO.iodata_to_binary(json_iodata)
  # Returns: "{\"address\":{\"city\":\"Berlin\",\"street\":\"Ystader Straße\"},\"age\":30,\"name\":\"Alice\"}"
end

# Decode JSON to a struct
json_string = ~s({"name":"Alice","age":30,"address":{"street":"Ystader Straße","city":"Berlin"}})
{:ok, person} = Spectral.decode(json_string, Person, :t, :json)

# Generate a JSON schema
schema_iodata = Spectral.schema(Person, :t)
IO.iodata_to_binary(schema_iodata)

Bang variants raise instead of returning error tuples:

json =
  person
  |> Spectral.encode!(Person, :t, :json)
  |> IO.iodata_to_binary()

person = Spectral.decode!(json_string, Person, :t, :json)

schema =
  Person
  |> Spectral.schema(:t)
  |> IO.iodata_to_binary()

Data Serialization API

Parameters for encode/3-5, decode/3-5, and schema/2-4:

The binary_string and string formats decode a single value from a binary or string — useful for path variables and query parameters:

defmodule MyTypes do
  use Spectral

  @type role :: :admin | :user
end

# Decode a role from a query parameter like "?role=admin"
{:ok, :admin} = Spectral.decode("admin", MyTypes, :role, :binary_string)
{:error, _} = Spectral.decode("superuser", MyTypes, :role, :binary_string)

# Encode a role back to a plain string
{:ok, "admin"} = Spectral.encode(:admin, MyTypes, :role, :binary_string)

Options

encode/5 and decode/5 accept an options list as the last argument:

Option Function Effect
pre_decodeddecode Input is already a parsed term — skips JSON decoding
pre_encodedencode, schema Returns a map/list instead of iodata() — skips JSON encoding
# Input already decoded by a web framework (e.g. Plug already ran Jason.decode!)
{:ok, person} = Spectral.decode(decoded_map, Person, :t, :json, [:pre_decoded])

# Get a map instead of iodata (e.g. to pass to a framework that does its own encoding)
{:ok, map} = Spectral.encode(person, Person, :t, :json, [:pre_encoded])

# Get the schema as a map instead of iodata
schema_map = Spectral.schema(Person, :t, :json_schema, [:pre_encoded])

Error Handling

encode/3-5 and decode/3-5 use a dual error handling strategy:

Data validation errors return {:error, [%Spectral.Error{}]}:

Configuration errors raise exceptions:

Use with for clean error handling:

bad_json = ~s({"name":"Alice","age":"not a number"})

with {:ok, person} <- Spectral.decode(bad_json, Person, :t, :json) do
  process_person(person)
end

Bang functions (encode!/3-5, decode!/3-5) raise for any error, including data validation errors. Use them when you want to propagate all errors as exceptions.

schema/2-3 returns iodata() directly (no result tuple) but may still raise for configuration errors.

Error Structure

Each %Spectral.Error{} has:

Nil Values, Extra Fields, and Unsupported Types

Nil values

Struct fields with nil values are omitted when encoding if the type allows nil. When decoding, missing fields and explicit JSON null both become nil if the type allows it:

person = %Person{name: "Alice"}  # age and address are nil

with {:ok, json_iodata} <- Spectral.encode(person, Person, :t, :json) do
  IO.iodata_to_binary(json_iodata)
  # Returns: "{\"name\":\"Alice\"}"  (age and address omitted)
end

Spectral.decode(~s({"name":"Alice"}), Person, :t, :json)
# Returns: {:ok, %Person{name: "Alice", age: nil, address: nil}}

Spectral.decode(~s({"name":"Alice","age":null}), Person, :t, :json)
# Returns: {:ok, %Person{name: "Alice", age: nil, address: nil}}

Extra fields

Extra JSON fields not present in the type specification are silently ignored, enabling forward compatibility:

json = ~s({"name":"Alice","age":30,"unknown_field":"ignored"})
Spectral.decode(json, Person, :t, :json)
# Returns: {:ok, %Person{name: "Alice", age: 30, address: nil}}

Unvalidated types

dynamic(), term(), and any() pass through without validation. The result may not be valid JSON if encoding such data.

Unsupported types

The following types cannot be serialized to JSON:

Custom Codecs

A codec is a module that provides custom encode, decode, and schema logic for a type. Implement the Spectral.Codec behaviour and add use Spectral.Codec to your module — spectra auto-detects it via the @behaviour attribute in the compiled BEAM, so no registration is needed for types defined in your own module.

Here is a codec that serializes a point tuple as a two-element JSON array:

defmodule MyGeoModule do
  use Spectral.Codec

  @opaque point :: {float(), float()}

  @impl Spectral.Codec
  def encode(_format, MyGeoModule, {:type, :point, 0}, {x, y}, _sp_type, _params)
      when is_number(x) and is_number(y) do
    {:ok, [x, y]}
  end

  def encode(_format, MyGeoModule, {:type, :point, 0}, data, _sp_type, _params) do
    {:error, [%Spectral.Error{type: :type_mismatch, location: [], context: %{type: {:type, :point, 0}, value: data}}]}
  end

  def encode(_format, _module, _type_ref, _data, _sp_type, _params), do: :continue

  @impl Spectral.Codec
  def decode(_format, MyGeoModule, {:type, :point, 0}, [x, y], _sp_type, _params)
      when is_number(x) and is_number(y) do
    {:ok, {x, y}}
  end

  def decode(_format, MyGeoModule, {:type, :point, 0}, data, _sp_type, _params) do
    {:error, [%Spectral.Error{type: :type_mismatch, location: [], context: %{type: {:type, :point, 0}, value: data}}]}
  end

  def decode(_format, _module, _type_ref, _input, _sp_type, _params), do: :continue

  @impl Spectral.Codec
  def schema(:json_schema, MyGeoModule, {:type, :point, 0}, _sp_type, _params) do
    %{type: "array", items: %{type: "number"}, minItems: 2, maxItems: 2}
  end
end

Each callback must return {:ok, result}, {:error, errors}, or :continue. Return {:error, ...} when the data is invalid for a type your codec owns, and :continue for types your codec does not handle.

Codec errors

Construct %Spectral.Error{} structs and always return them in {:error, [%Spectral.Error{}]} tuples (as shown above). Spectral collects errors from multiple locations and attaches path information as it traverses nested structures. See existing usages of %Spectral.Error{} in the codebase for examples.

Optional schema/5 callback

The schema/5 callback is optional. If a codec module does not export it, calling Spectral.schema/3 for a type owned by that codec raises {:schema_not_implemented, Module, TypeRef}. Return :continue for types the codec does not handle.

Codecs for third-party types

To handle types from modules you cannot annotate (stdlib, third-party libraries), register a codec globally (Note: you are configuring the Erlang library spectra here, not spectral):

Application.put_env(:spectra, :codecs, %{
  {SomeLibrary, {:type, :some_type, 0}} => MyCodec
})

Built-in Codecs

Spectral ships with codecs for Elixir's standard date/time types and MapSet. They are not active by default — register them in config/config.exs:

import Config

config :spectra, :codecs, %{
  {DateTime, {:type, :t, 0}} => Spectral.Codec.DateTime,
  {Date, {:type, :t, 0}} => Spectral.Codec.Date,
  {MapSet, {:type, :t, 0}} => Spectral.Codec.MapSet,
  {MapSet, {:type, :t, 1}} => Spectral.Codec.MapSet
}
Codec Elixir type JSON representation
Spectral.Codec.DateTimeDateTime.t() ISO 8601 / RFC 3339 string, e.g. "2012-04-23T18:25:43.511Z"
Spectral.Codec.DateDate.t() ISO 8601 date string, e.g. "2023-04-01"
Spectral.Codec.MapSetMapSet.t() / MapSet.t(elem) JSON array with uniqueItems: true in its schema

The date/time codecs handle :json and :binary_string formats. A string that fails to parse returns a type_mismatch error with %{reason: :invalid_format} in the error context.

Range and Stream do not have built-in codecs. Implement a custom Spectral.Codec if needed — PRs welcome.

Type Parameters

The type_parameters key in a spectral attribute attaches a static value to a type. This value is available to codecs as the params argument (6th argument to encode/6 and decode/6, 5th to schema/5). When type_parameters is absent, params is :undefined.

String and binary constraints

For String.t(), binary(), nonempty_binary(), and nonempty_string(), type_parameters can enforce structural constraints — no custom codec required:

Key JSON Schema keyword Validated at encode/decode? Notes
min_lengthminLength yes Unicode codepoint count, not byte count
max_lengthmaxLength yes Unicode codepoint count, not byte count
patternpattern yes PCRE regular expression
formatformat no Schema annotation only
defmodule MyTypes do
  use Spectral

  spectral type_parameters: %{min_length: 2, max_length: 64}
  @type username :: String.t()

  spectral type_parameters: %{pattern: "^[a-z0-9_]+$", format: "hostname"}
  @type slug :: String.t()
end

Encoding and decoding both enforce the constraints and return an error on failure. nonempty_binary() and nonempty_string() already imply minLength: 1; a min_length parameter overrides this.

Codec-specific configuration

type_parameters also lets you reuse one codec across multiple types with different configuration:

defmodule MyIds do
  use Spectral.Codec
  use Spectral

  spectral(type_parameters: "user_")
  @type user_id :: String.t()

  spectral(type_parameters: "org_")
  @type org_id :: String.t()

  @impl Spectral.Codec
  def encode(_format, MyIds, {:type, type, 0}, id, _sp_type, prefix)
      when type in [:user_id, :org_id] and is_binary(id) do
    {:ok, prefix <> id}
  end

  def encode(_format, MyIds, {:type, type, 0}, data, _sp_type, _prefix)
      when type in [:user_id, :org_id] do
    {:error, [%Spectral.Error{type: :type_mismatch, location: [], context: %{type: {:type, type, 0}, value: data}}]}
  end

  def encode(_format, _module, _type_ref, _data, _sp_type, _params), do: :continue

  @impl Spectral.Codec
  def decode(_format, MyIds, {:type, type, 0}, encoded, _sp_type, prefix)
      when type in [:user_id, :org_id] and is_binary(encoded) do
    prefix_len = byte_size(prefix)

    case encoded do
      <<^prefix::binary-size(prefix_len), id::binary>> -> {:ok, id}
      _ -> {:error, [%Spectral.Error{type: :type_mismatch, location: [], context: %{type: {:type, type, 0}, value: encoded}}]}
    end
  end

  def decode(_format, _module, _type_ref, _input, _sp_type, _params), do: :continue

  @impl Spectral.Codec
  def schema(_format, MyIds, {:type, type, 0}, _sp_type, prefix) when type in [:user_id, :org_id] do
    %{type: "string", pattern: "^" <> prefix}
  end
end

Documenting Types with spectral

You can add JSON Schema documentation to your types using the spectral macro. Place the spectral call immediately before the @type definition it documents:

defmodule Person do
  use Spectral

  defstruct [:name, :age]

  spectral title: "Person", description: "A person with name and age"
  @type t :: %Person{
    name: String.t(),
    age: non_neg_integer() | nil
  }
end

Supported fields for types:

defmodule Person do
  use Spectral

  defstruct [:name, :age]

  spectral title: "Person",
           description: "A person with name and age",
           examples_function: {__MODULE__, :examples, []}
  @type t :: %Person{name: String.t(), age: non_neg_integer()}

  def examples do
    [%Person{name: "Alice", age: 30}, %Person{name: "Bob", age: 25}]
  end
end

The generated schema will include the title and description:

schema = Spectral.schema(Person, :t) |> IO.iodata_to_binary() |> Jason.decode!()
# %{"title" => "Person", "description" => "A person with name and age", "type" => "object", ...}

Multiple types in one module — only types with a spectral call will have title/description in their schemas:

defmodule MyModule do
  use Spectral

  spectral title: "Public API", description: "The public interface"
  @type public_api :: map()

  # No spectral call — no title/description in schema
  @type internal_type :: atom()
end

Documenting Functions (Endpoint Metadata)

The spectral macro also works before @spec definitions to attach OpenAPI endpoint documentation:

defmodule MyController do
  use Spectral

  spectral summary: "Get user", description: "Returns a user by ID"
  @spec show(map(), map()) :: map()
  def show(_conn, _params), do: %{}
end

Supported fields for function specs:

This metadata is used by Spectral.OpenAPI.endpoint/5 to automatically populate OpenAPI operation fields — see the OpenAPI section below.

OpenAPI Specification

Note: Most users will not need to use Spectral.OpenAPI directly. Web framework integrations such as phoenix_spec build on top of it and provide a higher-level API. Use Spectral.OpenAPI only if you are building such an integration or need direct control over spec generation.

Spectral can generate complete OpenAPI 3.1 specifications for your REST APIs. This provides interactive documentation, client generation, and API testing tools.

OpenAPI Builder API

The API uses a fluent builder pattern for constructing endpoints and responses.

Building Responses

Code.ensure_loaded!(Person)

# Simple response
user_not_found_response =
  Spectral.OpenAPI.response(404, "User not found")

# Response with body
user_found_response =
  Spectral.OpenAPI.response(200, "User found")
  |> Spectral.OpenAPI.response_with_body(Person, :t)

user_created_response =
  Spectral.OpenAPI.response(201, "User created")
  |> Spectral.OpenAPI.response_with_body(Person, {:type, :t, 0})

users_found_response =
  Spectral.OpenAPI.response(200, "Users found")
  |> Spectral.OpenAPI.response_with_body(Person, {:type, :persons, 0})

# Response with response header
response_with_headers =
  Spectral.OpenAPI.response(200, "Success")
  |> Spectral.OpenAPI.response_with_body(Person, :t)
  |> Spectral.OpenAPI.response_with_header(
    "X-Rate-Limit",
    :t,
    %{description: "Requests remaining", required: false, schema: :integer}
  )

Building Endpoints

Use endpoint/5 to automatically pull documentation from a function's spectral annotation:

# Documentation comes from the spectral/1 annotation on MyController.show/2
user_get_endpoint =
  Spectral.OpenAPI.endpoint(:get, "/users/{id}", MyController, :show, 2)
  |> Spectral.OpenAPI.add_response(user_found_response)

Or use endpoint/3 to pass documentation inline:

user_get_endpoint =
  Spectral.OpenAPI.endpoint(:get, "/users/{id}", %{summary: "Get user by ID"})
  |> Spectral.OpenAPI.with_parameter(Person, %{
    name: "id",
    in: :path,
    required: true,
    schema: :string,
    description: "The user ID"
  })
  |> Spectral.OpenAPI.add_response(user_found_response)
  |> Spectral.OpenAPI.add_response(user_not_found_response)

# Add request body (for POST, PUT, PATCH)
# Description comes automatically from the spectral attribute on Person.t()
user_create_endpoint =
  Spectral.OpenAPI.endpoint(:post, "/users")
  |> Spectral.OpenAPI.with_request_body(Person, {:type, :t, 0})
  |> Spectral.OpenAPI.add_response(user_created_response)

# Override content type (defaults to "application/json")
user_create_xml_endpoint =
  Spectral.OpenAPI.endpoint(:post, "/users")
  |> Spectral.OpenAPI.with_request_body(Person, {:type, :t, 0}, "application/xml")
  |> Spectral.OpenAPI.add_response(user_created_response)

# Add query parameters
user_search_endpoint =
  Spectral.OpenAPI.endpoint(:get, "/users")
  |> Spectral.OpenAPI.with_parameter(Person, %{
    name: "search",
    in: :query,
    required: false,
    schema: :search
  })
  |> Spectral.OpenAPI.add_response(users_found_response)

Generating the OpenAPI Specification

Combine all endpoints into a complete OpenAPI spec:

metadata = %{
  title: "My API",
  version: "1.0.0",
  # Optional fields:
  summary: "Short summary of the API",
  description: "Longer description of the API",
  terms_of_service: "https://example.com/terms",
  contact: %{name: "Support", url: "https://example.com/support", email: "support@example.com"},
  license: %{name: "MIT", url: "https://opensource.org/licenses/MIT"},
  servers: [%{url: "https://api.example.com", description: "Production"}]
}

endpoints = [
  user_get_endpoint,
  user_create_endpoint,
  user_search_endpoint
]

{:ok, json} = Spectral.OpenAPI.endpoints_to_openapi(metadata, endpoints)

endpoints_to_openapi/2 returns {:ok, iodata} — the complete OpenAPI 3.1 spec serialised as JSON, ready to write to a file or serve over HTTP.

endpoints_to_openapi/3 accepts the same pre_encoded option as encode/5:

Options Return on success
(default) {:ok, iodata()} — encoded JSON
[:pre_encoded]{:ok, map()} — decoded map for further processing
{:ok, spec_map} = Spectral.OpenAPI.endpoints_to_openapi(metadata, endpoints, [:pre_encoded])

Configuration

Spectral is configured via the underlying :spectra application environment. Put this in config/config.exs (or an environment-specific file):

import Config

config :spectra,
  # Enable the type-info cache (recommended in production).
  # Type information is read from BEAM abstract code on every encode/decode/schema
  # call by default. The cache stores it in persistent_term, keyed by module
  # version, so it invalidates automatically on code reloads.
  use_module_types_cache: true,

  # Register codecs for types you cannot annotate directly (stdlib, third-party).
  # See the Built-in Codecs and Custom Codecs sections for details.
  codecs: %{
    {DateTime, {:type, :t, 0}} => Spectral.Codec.DateTime,
    {Date, {:type, :t, 0}} => Spectral.Codec.Date,
    {MapSet, {:type, :t, 0}} => Spectral.Codec.MapSet,
    {MapSet, {:type, :t, 1}} => Spectral.Codec.MapSet
  }

Related Projects

Development Status

This library is under active development. APIs may change in future versions.

Contributing

Contributions are welcome! Please feel free to submit issues and pull requests.

License

See LICENSE.md for details.