JSONCodec
Compile-time generated codecs for JSON-shaped Elixir structs.
JSONCodec is not another JSON parser. It uses Jason for parsing and focuses on the annoying part that tends to be rewritten in every Elixir project: converting decoded string-keyed JSON maps into nested structs with aliases, defaults, computed fields, explicit atom policy, and schema export.
JSONCodec uses normal Elixir declarations as the source of truth:
defstructfor fields and defaults@type tfor field typescodec/2only for JSON-specific field metadata
defmodule FunctionID do
use JSONCodec
defstruct [:module, :function, :arity, :id]
@type t :: %__MODULE__{
module: String.t(),
function: String.t(),
arity: non_neg_integer(),
id: String.t() | nil
}
computed :id, fn function ->
"#{function.module}.#{function.function}/#{function.arity}"
end
end
defmodule DataRef do
use JSONCodec
defstruct [:type, :function, :name, :index]
@type t :: %__MODULE__{
type: :argument | :return | :variable,
function: FunctionID.t(),
name: atom() | nil,
index: non_neg_integer() | nil
}
codec :name, atom: :unsafe
end
Generated API:
FunctionID.decode!(json)
FunctionID.decode(json)
FunctionID.from_map!(map)
FunctionID.from_map(map)
FunctionID.to_map(struct)
FunctionID.schema()
Top-level helpers are also available:
JSONCodec.decode!(json, FunctionID)
JSONCodec.from_map!(map, FunctionID)
JSONCodec.schema(FunctionID)
Why another JSON library?
Because this is not trying to compete with JSON parsers. It sits after parsing.
Most Elixir JSON code starts with Jason.decode!/1, then hand-rolls from_map!/1 functions forever:
def from_map!(%{"from" => from, "to" => to} = map) do
%DataFlow{
from: DataRef.from_map!(from),
to: DataRef.from_map!(to),
through: Enum.map(Map.get(map, "through", []), &DataRef.from_map!/1),
variable_names: Enum.map(Map.get(map, "variable_names", []), &String.to_atom/1)
}
end
JSONCodec generates that boring code from normal struct/typespec declarations.
| Library | Main job | Struct decode | Nested structs | Field aliases | Computed fields | Atom policy | Hot-path goal |
|---|---|---|---|---|---|---|---|
Jason | JSON parser/encoder | No | No | No | No | key option only | parsing speed |
Poisonas: | parser + old struct decode | Yes | Limited | No | No | key option | legacy parser path |
Spectral | typespec-driven serialization/schema | Yes | Yes | Yes | via codecs | safe existing atoms | validation/type coverage |
Exdantic/Elixact/Zoi/Drops | validation frameworks | Sometimes | Yes | Sometimes | Yes | framework-specific | validation UX |
Tarams | Phoenix params casting | Map output | Nested maps | Yes | transforms | casting-specific | request params |
SimpleSchema | JSON validation + struct | Yes | Yes | Yes | custom callbacks | limited | validation pipeline |
| JSONCodec | generated JSON-shaped struct codecs | Yes | Yes | Yes | Yes | explicit per field | near-handwritten decode |
Use Jason for parsing. Use Tarams/Ecto for Phoenix params. Use a validation framework when rich validation is the main goal. Use JSONCodec when you own the struct shape and want fast, boring, explicit map-to-struct codecs.
Codec metadata
Most fields need no JSONCodec-specific declaration. Defaults come from defstruct; types come from @type t.
defmodule PackageManifest do
use JSONCodec, case: :camel, fast_path: :json
defstruct [:name, :version, dev_dependencies: %{}]
@type t :: %__MODULE__{
name: String.t(),
version: String.t() | nil,
dev_dependencies: %{String.t() => String.t()}
}
end
:camel maps :dev_dependencies to "devDependencies" automatically.
fast_path: :json generates an optimized first from_map!/1 clause for normal Jason-decoded JSON maps with string keys. If that fast string-key clause does not match, JSONCodec falls back to the full generic decoder, including atom-key lookup and detailed missing-field handling.
Use codec/2 for exceptions and special behavior:
codec :not_found, as: "not_found"
codec :variable_names, atom: :unsafe
codec :rotate, transform: :normalize_rotate
Local callback atoms are expanded to functions in the same module:
codec :rotate, transform: :normalize_rotate
# calls normalize_rotate(value)
codec :icons, values: :icon_value
# calls icon_value(key, value, source_map)
Remote captures are also supported:
codec :rotate, transform: &MyTransforms.normalize_rotate/1
codec :icons, values: &MyTransforms.icon_value/3
Advanced map value callbacks
For map fields, values: transforms each raw map value before JSONCodec decodes it as the declared value type:
codec :icons, values: :icon_value
# icon_value(key, raw_value, source_map) -> raw_value_for_normal_decode
If that callback needs shared context, use values_source: to compute the third argument once per map field:
codec :icons, values: :icon_value, values_source: :icon_defaults
# icon_defaults(source_map) -> defaults
# icon_value(key, raw_value, defaults) -> raw_value_for_normal_decode
For map-heavy data where a custom decoder is clearer or faster, decode_values: returns the final decoded map value directly:
codec :icons, decode_values: :decode_icon, values_source: :icon_defaults
# icon_defaults(source_map) -> defaults
# decode_icon(key, raw_value, defaults) -> final decoded value
Remote captures work for these callbacks too:
codec :icons, values: &MyTransforms.icon_value/3,
values_source: &MyTransforms.icon_defaults/1
codec :icons, decode_values: &MyTransforms.decode_icon/3,
values_source: &MyTransforms.icon_defaults/1
Atom policy is explicit:
codec :status, atom: :existing
codec :variable_name, atom: :unsafe
:unsafe uses String.to_atom/1; only use it for bounded/trusted internal data.
Supported type shapes
Read from @type t:
String.t()integer()non_neg_integer()pos_integer()float()number()boolean()atom()any()/term()type | nil- atom unions like
:active | :inactive [type]%{String.t() => value_type}- another
JSONCodecmodule viaOther.t()
Schema export
Each codec module exports a JSON Schema-compatible map:
FunctionID.schema()
JSONCodec.schema(FunctionID)
json_schema/0 and JSONCodec.json_schema/1 are also available as explicit aliases.
This is intentionally compatible with the direction of JSONSpec: codecs are the fast construction layer; schema validation can remain a separate layer.
Benchmarks
Run:
MIX_ENV=dev mix run bench/program_facts_like.exs
Machine used for this snapshot: Apple M5, Elixir 1.20, Erlang/OTP 29. Payload: 142 KB, 250 nested data_flow records.
| Case | ips | avg | memory |
|---|---|---|---|
JSONCodec map→struct | 4119.81 | 0.24 ms | 0.35 MB |
| handwritten map→struct | 4009.64 | 0.25 ms | 0.25 MB |
Jason.decode only | 1378.28 | 0.73 ms | 1.10 MB |
Spectral pre-decoded | 1252.96 | 0.80 ms | 3.23 MB |
handwritten Jason+struct | 980.43 | 1.02 ms | 1.34 MB |
JSONCodecJason+struct | 972.52 | 1.03 ms | 1.45 MB |
Spectral native JSON | 654.31 | 1.53 ms | 4.06 MB |
Interpretation:
- With
fast_path: :json,JSONCodecis roughly tied with this handwritten decoder on decoded JSON maps, while still providing a generic fallback path. - End-to-end, JSON parsing dominates.
JSONCodec.decode!/1is within ~1.01× of handwrittenJason+struct and ~1.49× faster thanSpectralnative JSON on this shape. - On map-heavy Iconify-like data (
mix run bench/iconify_like.exs),values_source:avoids recomputing inherited defaults for every map entry. For advanced map-heavy decoders,decode_values:can return the final decoded map value directly when a custom decoder is clearer or faster than transforming a raw map and then invoking the generated nested decoder; in the Iconify-like benchmark this bringsJSONCodecclose to handwritten allocation. - The goal is not to beat perfect handwritten code on every shape immediately; it is to make the generated path close enough that hand-written decoders disappear.
Installation
{:json_codec, "~> 0.1.1"}
Development
See CHANGELOG.md for release notes.
This project was bootstrapped with VibeKit conventions.
mix deps.get
mix test
mix ci