guides/json-serialization.md

# JSON Serialization

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.

```elixir
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?`:

```elixir
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.

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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):

```elixir
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:

```elixir
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](#notgiven-sentinels) section
below for details.

```elixir
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:

```elixir
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:

```elixir
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

```elixir
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

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
# 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:

```elixir
Sinter.Schema.field_aliases(schema)
# => %{"account_name" => "accountName", "created_at" => "createdAt", "is_active" => "isActive"}
```