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 0

Validation 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:

  1. Key stringification (atom keys become string keys)
  2. Alias application
  3. Format application
  4. NotGiven/Omit sentinel removal
  5. 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)                    # => false

Guard-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}
end

Coalescing

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
end

Aliases 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"}