Ecto's embedded schemas and changesets give you casting and validation in a declarative API, and I reach for them constantly. The annoying part shows up when you integrate with an external spec like UCP or ACP that publishes JSON Schemas. Then you're hand-writing structs to match dozens of nested, cross-referenced definitions, and the required field you missed only turns up at runtime. Smelter takes a schema directory and gives you the Ecto modules back.
What it does
Smelter reads JSON Schema files and generates Elixir modules with Ecto.Schema embedded schemas and a changeset/2 function. Point it at a schema, tell it the module name you want:
{:ok, code} = Smelter.compile("priv/schemas/user.json", module: "MyApp.Schemas.User")
From a schema that defines a user with required name and email fields, you get:
defmodule MyApp.Schemas.User do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :age, :integer
field :email, :string
field :name, :string
end
def changeset(struct \\ %__MODULE__{}, params) do
struct
|> cast(params, [:age, :email, :name])
|> validate_required([:email, :name])
end
end
Required fields turn into validate_required. Format specifiers like date-time, uri, and uuid map to Elixir types. Enums and constants come across too. What you get are Ecto modules you can use directly to cast and validate external data.
Keeping up with schema changes
The payoff shows up once you have dozens or hundreds of definitions and they keep changing.
A large API usually means tens of schemas that reference each other through $ref pointers, share common definitions via $defs, and compose with allOf, oneOf, and anyOf. That's where the bugs come from when you write it by hand: a union type you misread, a nested struct you forgot to update when upstream changed it. Your changeset quietly drops a field the API now requires, and you find out in production.
Generate the code instead and the loop gets short. The schema changes, you regenerate, the compiler tells you what broke.
Batch generation
Smelter processes entire directories of schemas at once:
Smelter.Batch.generate(
schema_dir: "priv/schemas/2026-01-11",
output_dir: "lib/my_app/schemas",
module_prefix: "MyApp.Schemas"
)
It walks the directory, resolves cross-file references, and pulls $defs entries out into separate modules, all in one pass. The folder structure carries over into the module hierarchy, so the namespace mirrors how the schemas are organized.
Schema composition
The composition keywords are the tricky part of JSON Schema. allOf merges properties from multiple schemas into a single module. oneOf and anyOf generate union type modules that use a discriminator field to route to the correct variant. $defs entries become their own modules nested under the parent.
That covers the schemas you run into in real APIs, the deeply nested ones that reference each other all over the place.
Where it's used
Bazaar uses Smelter for all of its UCP (Universal Commerce Protocol) schema modules. One mix task processes the entire UCP specification and generates a type-safe Elixir module for every schema in the protocol. When the spec gets a new version, regenerate and the compiler catches the breaking changes right away.
Getting started
# mix.exs
{:smelter, "~> 0.1.0"}
Full documentation and source on GitHub.