# Spectral
Spectral provides type-safe data serialization and deserialization for Elixir types. Currently the focus is on JSON.
- **Type-safe conversion**: Convert typed Elixir values to/from external formats such as JSON, ensuring data conforms to the type specification
- **Detailed errors**: Get error messages with location information when validation fails
- **Support for complex scenarios**: Handles unions, structs, atoms, nested structures, and more
## 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`:
```elixir
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:
```elixir
# 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
```
```elixir
# 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:
```elixir
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`:
- `data` — The data to encode/decode (Elixir value for encode, binary/iodata for decode)
- `module` — The module where the type is defined (e.g., `Person`)
- `type_ref` — The type reference, typically an atom like `:t` for `@type t`
- `format` — (optional) The data format: `:json` (default), `:binary_string`, or `:string`
The `binary_string` and `string` formats decode a single value from a binary or string — useful for path variables and query parameters:
```elixir
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_decoded` | `decode` | Input is already a parsed term — skips JSON decoding |
| `pre_encoded` | `encode`, `schema` | Returns a map/list instead of `iodata()` — skips JSON encoding |
```elixir
# 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{}]}`:
- Type mismatches (e.g., string when integer expected)
- Missing required fields
- Invalid data structure
**Configuration errors** raise exceptions:
- Module not found, unloaded, or compiled without `debug_info`
- Type not found in the specified module
- Unsupported types (e.g., `pid()`, `port()`, `tuple()`)
Use `with` for clean error handling:
```elixir
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:
- `location` — path to the failing value, e.g. `["user", "age"]`
- `type` — `:type_mismatch`, `:missing_data`, `:no_match`, or `:not_matched_fields`
- `context` — additional context, e.g. `%{expected: :integer, got: "not a number"}`
- `message` — human-readable error message
## 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:
```elixir
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:
```elixir
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:
- `pid()`, `port()`, `reference()`
- `tuple()` (generic unstructured tuples)
- Function types
## 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:
```elixir
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`):
```elixir
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`:
```elixir
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.DateTime` | `DateTime.t()` | ISO 8601 / RFC 3339 string, e.g. `"2012-04-23T18:25:43.511Z"` |
| `Spectral.Codec.Date` | `Date.t()` | ISO 8601 date string, e.g. `"2023-04-01"` |
| `Spectral.Codec.MapSet` | `MapSet.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_length` | `minLength` | yes | Unicode codepoint count, not byte count |
| `max_length` | `maxLength` | yes | Unicode codepoint count, not byte count |
| `pattern` | `pattern` | yes | PCRE regular expression |
| `format` | `format` | no | Schema annotation only |
```elixir
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:
```elixir
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:
```elixir
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:**
- `title` — short title for the type
- `description` — longer description
- `deprecated` — marks the type as deprecated (boolean); emitted as `"deprecated": true` in the JSON Schema
- `examples` — list of example values
- `examples_function` — `{module, function_name, args}` tuple; the function is called at schema generation time to produce examples. Use this instead of `examples` when constructing values inline is awkward. The function must be exported.
- `type_parameters` — passed as `params` to codec callbacks (see [Custom Codecs](#custom-codecs))
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
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:**
- `summary` — short summary of the endpoint operation
- `description` — longer description
- `deprecated` — boolean
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](https://github.com/andreashasse/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](https://spec.openapis.org/oas/v3.1.0) 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
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
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 |
```elixir
{: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):
```elixir
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
- **[spectra](https://github.com/andreashasse/spectra)** - The underlying Erlang library that powers Spectral
## 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.