Sinter provides a complete JSON serialization pipeline through three cooperating
modules: Sinter.JSON for encoding and decoding, Sinter.Transform for
preparing data for serialization, and Sinter.NotGiven for distinguishing
omitted fields from explicit nil values.
Encoding
Sinter.JSON.encode/2 and Sinter.JSON.encode!/2 apply the transform pipeline
to your data, then encode the result to a JSON string via Jason.
data = %{
account_name: "Acme Corp",
created_at: ~U[2025-06-15 12:30:00Z],
deleted_at: nil
}
# Safe version returns {:ok, json} or {:error, reason}
{:ok, json} = Sinter.JSON.encode(data)
# => {:ok, "{\"account_name\":\"Acme Corp\",\"created_at\":\"2025-06-15 12:30:00Z\",\"deleted_at\":null}"}
# Bang version raises on failure
json = Sinter.JSON.encode!(data)Both functions accept the same options that Sinter.Transform.transform/2
supports -- :aliases, :formats, and :drop_nil?:
json = Sinter.JSON.encode!(data,
aliases: %{account_name: "accountName", created_at: "createdAt", deleted_at: "deletedAt"},
formats: %{created_at: :iso8601},
drop_nil?: true
)
# => "{\"accountName\":\"Acme Corp\",\"createdAt\":\"2025-06-15T12:30:00Z\"}"Decoding with Validation
Sinter.JSON.decode/3 and Sinter.JSON.decode!/3 decode a JSON string and
then validate the result against a Sinter schema. This combines parsing and
validation into a single step.
schema = Sinter.Schema.define([
{:name, :string, [required: true, min_length: 1]},
{:age, :integer, [optional: true, gt: 0]}
])
json = ~s({"name": "Alice", "age": 30})
# Safe version
{:ok, validated} = Sinter.JSON.decode(json, schema)
# => {:ok, %{"name" => "Alice", "age" => 30}}
# Bang version raises Sinter.ValidationError on failure
validated = Sinter.JSON.decode!(json, schema)When decoding fails -- whether due to malformed JSON or validation errors --
the error tuple contains a list of Sinter.Error structs:
bad_json = ~s({"age": -5})
{:error, errors} = Sinter.JSON.decode(bad_json, schema)
for error <- errors do
IO.puts(Sinter.Error.format(error))
end
# name: field is required
# age: must be greater than 0Validation options such as :coerce are passed through to the validator:
json = ~s({"name": "Bob", "age": "25"})
{:ok, validated} = Sinter.JSON.decode(json, schema, coerce: true)
# => {:ok, %{"name" => "Bob", "age" => 25}}Transform Pipeline
Sinter.Transform.transform/2 converts Elixir data structures into
JSON-friendly maps. It applies a fixed sequence of transformations:
- Key stringification (atom keys become string keys)
- Alias application
- Format application
- NotGiven/Omit sentinel removal
- nil dropping (when enabled)
The function recurses through maps, structs, and lists.
Key Stringification
Atom keys are automatically converted to strings:
Sinter.Transform.transform(%{user_name: "alice", active: true})
# => %{"user_name" => "alice", "active" => true}Alias Application
The :aliases option maps canonical keys to different output key names.
This is useful for converting between Elixir conventions (snake_case) and
API conventions (camelCase):
data = %{first_name: "Alice", last_name: "Smith"}
Sinter.Transform.transform(data,
aliases: %{first_name: "firstName", last_name: "lastName"}
)
# => %{"firstName" => "Alice", "lastName" => "Smith"}Format Application
The :formats option attaches formatters to specific keys. Sinter provides
the built-in :iso8601 formatter for DateTime, NaiveDateTime, and Date
values. You can also supply any unary function:
data = %{
created_at: ~U[2025-06-15 12:30:00Z],
starts_on: ~D[2025-07-01],
score: 0.9537
}
Sinter.Transform.transform(data,
formats: %{
created_at: :iso8601,
starts_on: :iso8601,
score: &Float.round(&1, 2)
}
)
# => %{"created_at" => "2025-06-15T12:30:00Z", "starts_on" => "2025-07-01", "score" => 0.95}NotGiven/Omit Sentinel Removal
Any field whose value is the NotGiven or Omit sentinel is silently removed
from the output. See the NotGiven Sentinels section
below for details.
alias Sinter.NotGiven
data = %{name: "Alice", nickname: NotGiven.value(), temp_token: NotGiven.omit()}
Sinter.Transform.transform(data)
# => %{"name" => "Alice"}nil Dropping
When :drop_nil? is set to true, keys with nil values are removed:
data = %{name: "Alice", bio: nil, avatar_url: nil}
Sinter.Transform.transform(data, drop_nil?: true)
# => %{"name" => "Alice"}Schema-Based Aliases
Instead of passing an explicit :aliases map, you can reference a schema
and set :use_aliases to true. The transform will extract aliases defined
on the schema's fields:
schema = Sinter.Schema.define([
{:account_name, :string, [required: true, alias: "accountName"]},
{:is_active, :boolean, [optional: true, alias: "isActive"]}
])
data = %{account_name: "Acme", is_active: true}
Sinter.Transform.transform(data, schema: schema, use_aliases: true)
# => %{"accountName" => "Acme", "isActive" => true}Explicit :aliases are merged on top of schema-derived aliases, so you can
override individual keys when needed.
NotGiven Sentinels
Sinter.NotGiven provides sentinel values for distinguishing between a field
that was intentionally omitted and one that was explicitly set to nil. This
pattern is common in API clients where you need to differentiate "do not send
this field" from "set this field to null."
Creating Sentinels
alias Sinter.NotGiven
# The NotGiven sentinel -- field was not provided
not_given = NotGiven.value()
# The Omit sentinel -- field should be explicitly dropped
omit = NotGiven.omit()Checking Sentinels
NotGiven.not_given?(NotGiven.value()) # => true
NotGiven.not_given?(nil) # => false
NotGiven.not_given?("hello") # => false
NotGiven.omit?(NotGiven.omit()) # => true
NotGiven.omit?(nil) # => falseGuard-friendly versions are also available:
import Sinter.NotGiven, only: [is_not_given: 1, is_omit: 1]
case value do
v when is_not_given(v) -> :skip
v when is_omit(v) -> :drop
v -> {:use, v}
endCoalescing
NotGiven.coalesce/2 replaces sentinel values with a fallback, leaving all
other values untouched:
NotGiven.coalesce(NotGiven.value(), "default") # => "default"
NotGiven.coalesce(NotGiven.omit(), "default") # => "default"
NotGiven.coalesce(nil, "default") # => nil
NotGiven.coalesce("hello", "default") # => "hello"Practical Example
A typical use case is building request payloads with optional fields:
alias Sinter.NotGiven
defmodule UserUpdateRequest do
defstruct name: NotGiven.value(),
email: NotGiven.value(),
bio: NotGiven.value()
end
# Caller only wants to update the email
request = %UserUpdateRequest{email: "new@example.com"}
# Transform strips the NotGiven fields automatically
payload = Sinter.Transform.transform(request)
# => %{"email" => "new@example.com"}
# Explicitly setting bio to nil sends null in the payload
request = %UserUpdateRequest{email: "new@example.com", bio: nil}
payload = Sinter.Transform.transform(request)
# => %{"email" => "new@example.com", "bio" => nil}Field Aliases
Field aliases allow you to decouple the internal (Elixir-side) field name from
the external (JSON-side) field name. Define an alias on a schema field with
the alias: option:
schema = Sinter.Schema.define([
{:account_name, :string, [required: true, alias: "accountName"]},
{:created_at, :string, [required: true, alias: "createdAt"]},
{:is_active, :boolean, [optional: true, alias: "isActive"]}
])Or using the compile-time DSL:
defmodule AccountSchema do
use Sinter.Schema
use_schema do
field :account_name, :string, required: true, alias: "accountName"
field :created_at, :string, required: true, alias: "createdAt"
field :is_active, :boolean, optional: true, alias: "isActive"
end
endAliases affect two areas:
Aliases in Transform and Encoding
When you pass schema: schema, use_aliases: true to Sinter.Transform.transform/2
(or indirectly through Sinter.JSON.encode/2), canonical field names are
replaced by their aliases in the output:
data = %{account_name: "Acme", created_at: "2025-01-15", is_active: true}
Sinter.Transform.transform(data, schema: schema, use_aliases: true)
# => %{"accountName" => "Acme", "createdAt" => "2025-01-15", "isActive" => true}Aliases in JSON Schema Generation
Sinter.JsonSchema.generate/1 uses alias names as property keys in the
generated JSON Schema, so the schema reflects the wire format:
json_schema = Sinter.JsonSchema.generate(schema)
json_schema["properties"]
# => %{
# "accountName" => %{"type" => "string"},
# "createdAt" => %{"type" => "string"},
# "isActive" => %{"type" => "boolean"}
# }
json_schema["required"]
# => ["accountName", "createdAt"]Aliases in Validation
During validation, Sinter.Validator.validate/3 accepts data keyed by either
the canonical name or the alias. The alias takes precedence when both are
present:
# Data using alias keys (e.g., decoded from an API response)
api_data = %{"accountName" => "Acme", "createdAt" => "2025-01-15"}
{:ok, validated} = Sinter.Validator.validate(schema, api_data)
# => {:ok, %{"account_name" => "Acme", "created_at" => "2025-01-15"}}Querying Aliases
You can retrieve the alias map from a schema programmatically:
Sinter.Schema.field_aliases(schema)
# => %{"account_name" => "accountName", "created_at" => "createdAt", "is_active" => "isActive"}