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