Smelter: JSON Schema to Elixir Code Generator

Stop hand-coding Ecto structs for external APIs. Smelter generates them from JSON Schema, including $ref resolution, composition, and batch processing.

Smelter: JSON Schema to Elixir Code Generator
Photo by Ludomił Sawicki / Unsplash

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.


George Guimarães builds agentic commerce infrastructure at New Generation. Previously: Principal Engineer at a unicorn fintech, co-founder of Plataformatec (acqui-hired by Nubank).


Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to George Guimarães..

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.