Spectral

View Source

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

Installation

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

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

Usage

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

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

# 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) 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)

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

Bang Functions

For convenience, Spectral provides bang versions (!) of all main functions that raise exceptions instead of returning error tuples:

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

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

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

Use bang functions when you want exceptions instead of explicit error handling.

Nil Value Handling

Spectral automatically omits nil values from JSON output for optional struct fields:

# Only required fields
person = %Person{name: "Alice"}

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

# When decoding, both missing fields and explicit null values become nil in structs
Spectral.decode(~s({"name":"Alice"}), Person, :t)
# Returns: {:ok, %Person{name: "Alice", age: nil, address: nil}}

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

Extra Fields Handling

When decoding JSON into Elixir structs, extra fields that are not defined in the type specification are silently ignored. This enables forward compatibility and flexible API evolution:

# JSON with extra fields not in the Person type
json = ~s({"name":"Alice","age":30,"unknown_field":"ignored"})

Spectral.decode(json, Person, :t)
# Returns: {:ok, %Person{name: "Alice", age: 30, address: nil}}
# Extra fields are discarded without errors

This permissive behavior allows your application to accept JSON from newer API versions without breaking, as long as all required fields are present.

Data Serialization API

The main functions for JSON serialization and deserialization (pipe-friendly):

# Regular versions (return tuples)
Spectral.encode(data, module, type_ref, format \\ :json) ::
    {:ok, iodata()} | {:error, [%Spectral.Error{}]}

Spectral.encode!(data, module, type_ref, format \\ :json) :: iodata()

Spectral.decode(data, module, type_ref, format \\ :json) ::
    {:ok, dynamic()} | {:error, [%Spectral.Error{}]}

Spectral.decode!(data, module, type_ref, format \\ :json) :: dynamic()

Parameters:

  • data - The data to encode/decode (Elixir value for encode, binary/string for decode)
  • module - The module where the type is defined (e.g., Person)
  • type_ref - The type reference, typically an atom like :t for the @type t definition
  • format - (optional) The data format: :json (default), :binary_string, or :string

Schema API

Generate schemas from your type definitions:

Spectral.schema(module, type_ref, format \\ :json_schema) :: iodata()

Parameters:

  • module - The module where the type is defined
  • type_ref - The type reference
  • format - (optional) Schema format, currently supports :json_schema (default)

OpenAPI Specification

Spectral can generate complete OpenAPI 3.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. While experimental and subject to change, it's designed to be used by web framework developers.

Building Responses

Responses are constructed using a builder pattern:

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

Endpoints are built by combining the endpoint definition with responses, request bodies, and parameters: Responses are taken from the previous section.

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


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

# Add 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"
}


endpoints = [
  #user_get_endpoint,
  user_create_endpoint,
  #user_search_endpoint
]

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

IO.inspect(openapi_spec, pretty: true)

Requirements

  • Erlang/OTP 27+: Spectral requires Erlang/OTP version 27 or later (required by the underlying spectra library)
  • Compilation: Modules must be compiled with debug_info for Spectral to extract type information. This is enabled by default in Mix projects.

Error Handling

Spectral provides two types of functions with different error handling strategies:

Normal Functions

The encoding and decoding functions (encode/3-4, decode/3-4) use a dual error handling approach:

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

  • Type mismatches (e.g., string when integer expected)
  • Missing required fields
  • Invalid data structure
  • Decoding failures

Use with for clean error handling:

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

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

Type and configuration errors raise exceptions:

  • Module not found, unloaded, or compiled without debug_info
  • Type not found in the specified module
  • Unsupported types used (e.g., pid(), port(), tuple())

These exceptions indicate problems with your application's configuration or type definitions, not with the data being processed.

Bang Functions

The bang versions (encode!/3-4, decode!/3-4) always raise exceptions for any error:

person =
  bad_json
  |> Spectral.decode!(Person, :t)
  |> process_person()

Use bang functions when you want to propagate all errors as exceptions, simplifying pipelines but requiring try/rescue for error handling.

Schema Generation

The schema/2-3 function returns the schema directly as iodata() without wrapping it in a result tuple:

schema = Spectral.schema(Person, :t)
IO.iodata_to_binary(schema)

Schema generation may still raise exceptions for type and configuration errors (module not found, type not found, etc.).

Error Structure

Each Spectral.Error struct represents a single error with the following fields:

  • location - Path showing where the error occurred (e.g., ["user", "age"])
  • type - Error type: :decode_error, :type_mismatch, :no_match, :missing_data, :not_matched_fields
  • context - Additional context information about the error
  • message - Human-readable error message (auto-generated)

Functions return {:error, [%Spectral.Error{}]} - a list of error structs:

{:error, [
  %Spectral.Error{
    location: ["user", "age"],
    type: :type_mismatch,
    context: %{expected: :integer, got: "not a number"},
    message: "type_mismatch at user.age"
  }
]}

Special Handling

nil Values

In Elixir structs, nil values are handled specially:

  • When encoding to JSON, struct fields with nil values are omitted from the output if the type includes nil as a valid value
  • When decoding from JSON, missing fields become nil if the type specification allows it

Example:

@type t :: %Person{
  name: String.t(),
  age: non_neg_integer() | nil  # nil is allowed
}

dynamic(), term() and any()

When using types with dynamic(), term(), or any() in your type specifications, Spectral will not reject any data, which means it can return data that may not be valid JSON.

Note: Spectral uses dynamic() for runtime-determined types in its own API, following Erlang's gradual typing conventions.

Unsupported Types

For JSON serialization and schema generation, the following Erlang/Elixir types are not supported:

  • pid(), port(), reference() - Cannot be serialized to JSON
  • tuple() (generic tuples without specific structure)
  • Function types - Cannot be serialized
  • 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.