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:
Jason.decode!(json)
Map.get(map, "field")
%{field: map["field"]}
Instead:
- Define a boundary struct that mirrors the JSON shape.
- Decode with
JSONCodec. - Convert once into a separate domain struct only when the internal concept differs from the wire shape.
Basic boundary struct
External JSON:
{"name":"demo","item_count":3,"enabled":true}
Boundary struct:
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:
ImportSummary.decode(json)
Decode already-decoded JSON map:
ImportSummary.from_map(map)
Use built-in case conversion
If JSON uses camelCase and Elixir uses snake_case, use JSONCodec's built-in casing:
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:
job_id <-> "jobId"
queued_at <-> "queuedAt"
retry_count <-> "retryCount"
Do not write redundant aliases for ordinary casing:
codec(:job_id, as: "jobId") # unnecessary with case: :camel
Use codec(:field, as: ...) only for field names that cannot be derived by case conversion:
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:
{"id":"job-1","createdAtMs":1782703591000}
Boundary struct:
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:
codec :created_at, cast: :from_milliseconds
codec :created_at, cast: &MyCasts.from_milliseconds/1
Processing order
Field processing order is:
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:
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:
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:
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:
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:
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 hocMap.getchains. - 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; usecast:. - Do not use
String.to_atom/1for JSON values; usecodec(..., atom: {:enum, values})for finite vocabularies oratom: :existingfor pre-existing atoms. - Do not hide JSONCodec errors behind generic
:invalidor string errors at the boundary.
Quick checklist
Before writing JSON parsing code, verify:
- There is a
defstructwithuse JSONCodecfor 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.