Understanding JSON → Elixir
Structs, maps, and the @enforce_keys trick.
Why Elixir JSON is more "structure the result" than "deserialize into a class", and how typespecs add the type information the runtime doesn't carry.
Structs are tagged maps.
An Elixir struct is a special map with a fixed set of keys and a type tag (the module name). Define one with defstruct [:id, :name, :avatar] inside a module; the struct is then accessed as %User{id: 7, name: "Q"}. Unlike map keys, struct keys are checked at compile time — a typo on %User{naem: ...} is a compile-time error. Structs are the right home for JSON DTOs.
@enforce_keys for required fields.
By default, struct fields default to nil when not provided. To require fields, add @enforce_keys [:id, :name] above the defstruct. Construction without those keys raises an ArgumentError at runtime. The combination (@enforce_keys plus defstruct) gives you required vs optional declaratively. Tools that emit Elixir code should enforce-keys every field that was present in every sample.
Jason is the standard library.
Jason (by Michał Muskała, José Valim's recommendation) is the de-facto JSON library — fast, simple, takes maps in and out by default. The output of Jason.decode!(json) is a plain map with string keys, which then gets mapped into your struct. There's no "annotation" step — the structure is built piece by piece, often via pattern-matching or with helper functions.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null }: defmodule User do
@enforce_keys [:user_id, :name]
defstruct [:user_id, :name, :avatar]
@type t :: %__MODULE__{
user_id: integer(),
name: String.t(),
avatar: String.t() | nil
}
def from_json(%{"user_id" => id, "name" => name} = m) do
%__MODULE__{user_id: id, name: name, avatar: m["avatar"]}
end
end The from_json function pattern-matches on the required keys (raises if missing) and accesses the optional one with the map index. The typespec gives Dialyzer the type information.
Pattern-match construction
%{"user_id" => id, "name" => name} = m
Pattern match guarantees required keys before the body runs.
User.from_json(%{"user_id" => 7, "name" => "Q"})
= %User{user_id: 7, name: "Q", avatar: nil}
Typespecs and Dialyzer.
Elixir is dynamically typed at runtime, but typespecs ( @type t :: %__MODULE__{...}, @spec foo(integer()) :: String.t() ) give Dialyzer enough information to flag type errors statically. For DTOs this is basically free type-safety: declare the typespec alongside the struct, then mix dialyzer catches misuses. The codegen tool should emit both the struct and the typespec.
Atoms, strings, and the safety dance.
Elixir maps can have either atom keys (:user_id) or string keys ("user_id"). Structs always have atom keys. Jason decodes JSON to string-keyed maps by default. Some codebases convert string keys to atoms with Jason.decode!(json, keys: :atoms); this is dangerous on untrusted input because atoms are not garbage-collected and a malicious JSON full of unique keys can exhaust atom-table memory. Use :atoms! (only existing atoms) or stay string-keyed and map by hand. Tools that emit the from_json helper should assume string keys.
Ecto changesets for validation.
Beyond simple structs, Phoenix-flavoured projects use Ecto changesets — pipelines of cast, validate_required, validate_length, validate_format. The result is either a valid changeset (apply it to get the struct) or an invalid one carrying a list of errors. For data that's crossing a trust boundary (HTTP body, file upload, message queue), Ecto changesets are the idiomatic upgrade from plain from_json functions.