Skip to main content

SKILL.md

---
name: json-codec-consumer
description: Use JSONCodec correctly in Elixir projects when decoding JSON-shaped maps/strings into structs at HTTP, config, file, event, provider, CLI, or protocol boundaries. Prefer this over Jason.decode! + hand-written map parsing.
---

# JSONCodec Consumer Rules

Use this skill whenever code decodes JSON or JSON-shaped maps into Elixir data.

Common examples:

- HTTP API responses
- HTTP request bodies
- config files
- cached JSON files
- event payloads
- webhook payloads
- provider protocol payloads
- CLI tool JSON output
- JWT or envelope payloads

## Core rule

Do **not** hand-roll JSON boundary parsing with:

```elixir
Jason.decode!(json)
Map.get(map, "field")
%{field: map["field"]}
```

Instead:

1. Define a boundary struct that mirrors the JSON shape.
2. Decode with `JSONCodec`.
3. Convert once into a separate domain struct only when the internal concept differs from the wire shape.

## Basic boundary struct

External JSON:

```json
{"name":"demo","item_count":3,"enabled":true}
```

Boundary struct:

```elixir
defmodule ImportSummary do
  use JSONCodec, strict: true, fast_path: :json

  defstruct [:name, :item_count, :enabled]

  @type t :: %__MODULE__{
          name: String.t(),
          item_count: non_neg_integer(),
          enabled: boolean()
        }
end
```

Decode JSON string:

```elixir
ImportSummary.decode(json)
```

Decode already-decoded JSON map:

```elixir
ImportSummary.from_map(map)
```

## Use built-in case conversion

If JSON uses camelCase and Elixir uses snake_case, use JSONCodec's built-in casing:

```elixir
defmodule JobEvent do
  use JSONCodec, case: :camel, strict: true, fast_path: :json

  defstruct [:job_id, :queued_at, :retry_count]

  @type t :: %__MODULE__{
          job_id: String.t(),
          queued_at: String.t(),
          retry_count: non_neg_integer()
        }
end
```

This maps automatically:

```text
job_id      <-> "jobId"
queued_at   <-> "queuedAt"
retry_count <-> "retryCount"
```

Do **not** write redundant aliases for ordinary casing:

```elixir
codec(:job_id, as: "jobId") # unnecessary with case: :camel
```

Use `codec(:field, as: ...)` only for field names that cannot be derived by case conversion:

```elixir
defmodule ClaimsEnvelope do
  use JSONCodec, strict: true, fast_path: :json

  defstruct [:standard_claims, :custom_claims]

  @type t :: %__MODULE__{
          standard_claims: map() | nil,
          custom_claims: map() | nil
        }

  codec(:custom_claims, as: "https://example.com/custom_claims")
end
```

## Cast wire values before type decode

Use `cast:` when JSON sends one representation but the struct field should have another declared Elixir type.

External JSON:

```json
{"id":"job-1","createdAtMs":1782703591000}
```

Boundary struct:

```elixir
defmodule JobPayload do
  use JSONCodec, case: :camel, strict: true, fast_path: :json

  defstruct [:id, :created_at]

  @type t :: %__MODULE__{
          id: String.t(),
          created_at: DateTime.t()
        }

  codec :created_at, as: "createdAtMs", cast: :from_milliseconds

  def from_milliseconds(milliseconds), do: DateTime.from_unix!(milliseconds, :millisecond)
end
```

Callback forms:

```elixir
codec :created_at, cast: :from_milliseconds
codec :created_at, cast: &MyCasts.from_milliseconds/1
```

## Processing order

Field processing order is:

```text
key mapping -> raw value -> cast -> type decode -> transform -> struct
```

- `case:` / `as:` maps JSON keys to struct fields.
- `cast:` converts raw wire values before type decoding.
- `transform:` normalizes already-decoded values.

Example transform:

```elixir
defmodule UserPayload do
  use JSONCodec, strict: true, fast_path: :json

  defstruct [:name]

  @type t :: %__MODULE__{name: String.t()}

  codec(:name, transform: :trim_name)

  def trim_name(name), do: String.trim(name)
end
```

Do not use `transform:` when the raw JSON value is not already decodable as the declared field type. Use `cast:` instead.

## Strict JSON maps

`JSONCodec.from_map/1` normally supports generic map decoding. If the input is supposed to be decoded JSON from an external boundary, use `strict: true`:

```elixir
use JSONCodec, strict: true, fast_path: :json
```

This rejects atom-key maps and prevents loose mixed atom/string boundary contracts.

## Error handling

Keep JSONCodec errors structured:

```elixir
case JobPayload.decode(json) do
  {:ok, payload} -> {:ok, payload}
  {:error, reason} -> {:error, {:invalid_job_payload, reason}}
end
```

Avoid converting codec errors into vague strings too early.

## Nested objects and maps

Use nested JSONCodec structs instead of manual recursive parsing:

```elixir
defmodule PackageFile do
  use JSONCodec, strict: true, fast_path: :json

  defstruct [:path, :bytes]

  @type t :: %__MODULE__{path: String.t(), bytes: non_neg_integer()}
end

defmodule PackageManifest do
  use JSONCodec, case: :camel, strict: true, fast_path: :json

  defstruct [:name, files: []]

  @type t :: %__MODULE__{
          name: String.t(),
          files: [PackageFile.t()]
        }
end
```

For maps with structured values, use typed map fields:

```elixir
defmodule Catalog do
  use JSONCodec, strict: true, fast_path: :json

  defstruct entries: %{}

  @type t :: %__MODULE__{entries: %{String.t() => PackageFile.t()}}
end
```

Map-field callbacks:

- `values:` transforms each raw map value before nested decode.
- `decode_values:` returns the final map value and bypasses nested decode.
- `values_source:` computes shared callback context once per map field.

## Do not

- Do not use `Jason.decode!` followed by ad hoc `Map.get` chains.
- Do not support mixed atom/string keys at external JSON boundaries.
- Do not write redundant `as:` mappings for normal camelCase/snake_case conversion.
- Do not use `transform:` for wire-type conversion; use `cast:`.
- Do not use `String.to_atom/1` for JSON values; use `codec(..., atom: {:enum, values})` for finite vocabularies or `atom: :existing` for pre-existing atoms.
- Do not hide JSONCodec errors behind generic `:invalid` or string errors at the boundary.

## Quick checklist

Before writing JSON parsing code, verify:

- [ ] There is a `defstruct` with `use JSONCodec` for the external JSON shape.
- [ ] External JSON boundaries use `strict: true`.
- [ ] CamelCase fields use `case: :camel`.
- [ ] `as:` is only used for non-case-convertible field names.
- [ ] Wire-to-Elixir value conversions use `cast:`.
- [ ] Same-type normalization uses `transform:`.
- [ ] Errors preserve JSONCodec details.