Worked examples across a range of use cases. See the tutorial first if you haven't already, and the DXN examples for format-level (not Elixir-API-level) examples.

A config file

# config.dxn
@dxn "1.0"
%{
  env:      :production
  debug:    false
  db: %{
    host:     "db.internal"
    port:     5432
    pool:     10
    timeout:  @time "00:00:30"
  }
  features: @{:billing :notifications}
}
{:ok, config} = File.read!("config.dxn") |> Dextrin.decode(trusted: true)
config.db.host
#=> "db.internal"
MapSet.member?(config.features, :billing)
#=> true

Shorthand keys (db:, features:) are DXN keywords, which decode as real atoms when the input is trusted (trusted: true, for your own config file) — that's what makes the dot-access above work directly. Without it -- the default, and what any file you don't fully control gets -- they decode as Dextrin.Keywords: use Dextrin.Keyword.new("db")-style keys (or Map.fetch/2) instead.

An API payload with an explicit schema

schema_source = """
%{
  User: %schema{
      id:       :uuid
      email:    NonEmptyString
      role:     {:enum :admin :member :guest}
      created:  :timestamp
      note?:    :string
    }
}
"""

{:ok, doc} = Dextrin.decode(schema_source)
{:ok, registry} = Dextrin.Schema.compile(doc, Dextrin.Schema.Std.registry())

payload = """
%User{
  id:      @uuid "550e8400-e29b-41d4-a716-446655440000"
  email:   "ada@example.com"
  role:    :admin
  created: @datetime "2024-01-01T00:00:00Z"
}
"""

{:ok, user} = Dextrin.decode(payload, registry: registry)
#=> {:ok, %{"id" => %Dextrin.Uuid{...}, "email" => "ada@example.com", "role" => :admin, ...}}

An event log, streamed to .dxnb for storage

events = [
  %{type: :login, user_id: 1, at: ~U[2024-01-01 08:00:00Z]},
  %{type: :purchase, user_id: 1, amount: Decimal.new("19.99"), at: ~U[2024-01-01 08:05:00Z]}
]

encoded = Enum.map(events, fn event ->
  {:ok, bytes} = Dextrin.encode_binary(event)
  bytes
end)

# ... write each to a file/stream, one .dxnb value per line/frame ...

decoded = Enum.map(encoded, fn bytes ->
  {:ok, event} = Dextrin.decode_binary(bytes)
  event
end)

.dxnb is a good fit here specifically because timestamp uses tag 1's integer microsecond form (never the lossy float form) and decimal maps directly to CBOR tag 4 — money and event timestamps round-trip exactly, unlike JSON-over-the-wire where both would need an app-level convention.

Money: struct + refine, combined

schema_source = """
%{
  Money: %schema{
      amount:   :decimal
      currency: {:enum :usd :eur :gbp}
    }
}
"""

{:ok, doc} = Dextrin.decode(schema_source)
{:ok, registry} = Dextrin.Schema.compile(doc)

registry =
  Dextrin.Registry.put_struct_materializer(registry, "Money", fn "Money", _version, fields ->
    {:ok, %{amount: fields["amount"], currency: fields["currency"]}}
  end)

Dextrin.decode(~s(%Money{amount: 19.99M, currency: :usd}), registry: registry)
#=> {:ok, %{amount: Decimal.new("19.99"), currency: :usd}}

What a materializer answers is checked against the latest schema, so it has to stay a value of the schema's own shape: answering currency: "usd" here would be refused (schema/materializer), because the schema declares {:enum :usd :eur :gbp}, not a string.

Upgrading an older version

One materializer per schema handles every version. A struct written with an older version than the latest loaded is read with its own version's schema and handed to the materializer, which fills in what that version lacked:

registry =
  Enum.reduce(
    ["%{User: %schema{name: :string}}", "%{User: %schema(2){name: :string, email: :string}}"],
    Dextrin.Registry.new(),
    fn source, registry ->
      {:ok, doc} = Dextrin.decode(source)
      {:ok, registry} = Dextrin.Schema.compile(doc, registry)
      registry
    end
  )
  |> Dextrin.Registry.put_struct_materializer("User", fn
    "User", 1, %{"name" => name} -> {:ok, %{name: name, email: "#{name}@example.com"}}
    "User", 2, fields -> {:ok, %{name: fields["name"], email: fields["email"]}}
  end)

Dextrin.decode(~s|%User(1){name: "ada"}|, registry: registry)
#=> {:ok, %{name: "ada", email: "ada@example.com"}}

A third-party struct provider, end to end

Dextrin.Schema.Provider lets a struct's own library define its DXN schema without depending on dextrin — see Dextrin.Schema.Provider's own moduledoc for the complete rationale (why a companion module, why a behaviour, why explicit registration, the optional-dependency mechanics in full). This example shows both sides: the geo library that owns the struct, and the application that uses it.

The library, geo_lib, ships a Geo.LatLng struct. It never depends on dextrin directly — only optionally, purely for this one companion module:

# geo_lib's own mix.exs
defp deps do
  [{:dextrin, "~> 0.3", optional: true}]
end
defmodule Geo.LatLng do
  defstruct [:lat, :lng]
end

# lib/geo/lat_lng/dxn.ex -- only compiles if the final application
# also depends on dextrin; Geo.LatLng itself is never touched.
if Code.ensure_loaded?(Dextrin.Schema.Provider) do
  defmodule Geo.LatLng.DXN do
    @behaviour Dextrin.Schema.Provider

    # Defines a shared named type (Degrees) alongside the one schema
    # this module is actually "for" -- both get compiled into the
    # registry together, per Dextrin.Schema.Provider's own moduledoc.
    @impl true
    def dxn_schema, do: """
    %{
      Degrees: {:refine :float %{min: -180.0, max: 180.0}}
      LatLng: %schema{ lat: Degrees, lng: Degrees }
    }
    """

    @impl true
    def dxn_schema_name, do: "LatLng"

    @impl true
    def dxn_struct, do: Geo.LatLng

    @impl true
    def dxn_materialize("LatLng", _version, %{"lat" => lat, "lng" => lng}),
      do: {:ok, %Geo.LatLng{lat: lat, lng: lng}}
  end
end

The application depends on both geo_lib and dextrin. It composes geo_lib's provider with dextrin's own standard named types in one registry, exactly like chaining any other base_registry:

{:ok, registry} = Dextrin.Schema.register_provider(Dextrin.Schema.Std.registry(), Geo.LatLng.DXN)

Dextrin.decode(~s(%LatLng{lat: 51.05, lng: 13.74}), registry: registry)
#=> {:ok, %Geo.LatLng{lat: 51.05, lng: 13.74}}

Dextrin.encode(%Geo.LatLng{lat: 51.05, lng: 13.74}, registry: registry)
#=> {:ok, "%LatLng(1){lat:51.05,lng:13.74}"}

# Degrees' own refine constraint is enforced automatically, same as
# any other schema -- no special handling needed on either side:
Dextrin.decode(~s(%LatLng{lat: 200.0, lng: 13.74}), registry: registry)
#=> {:error, %Dextrin.Error{message: "struct \"LatLng\" violates its schema: field \"lat\" does not match its declared type", ...}}

geo_lib never mentions Dextrin.Registry, Dextrin.Schema, or any other dextrin module outside that one guarded file — an application that uses Geo.LatLng without dextrin installed compiles and runs exactly as if Geo.LatLng.DXN didn't exist.

Cross-file schemas with Dextrin.Schema.FileResolver

Given schemas/Address.dxns and schemas/Person.dxns on disk, where Person.dxns references Address/Address (a namespaced reference):

resolver = Dextrin.Schema.FileResolver.for_paths(["schemas"])
registry = Dextrin.Registry.put_resolver(Dextrin.Registry.new(), resolver)

Dextrin.decode(~s(%Person{name: "Ada", home: %Address{street: "1 Main St"}}), registry: registry)

Each referenced schema file is loaded and compiled lazily, on first encounter with its name, and memoized for the rest of that decode.

Generating a schema scaffold from an existing struct

mix dextrin.gen.schema MyApp.Point --out point.dxns --name Point
%{
  Point: %schema{
      x?: :integer
      y?: :integer
    }
}

A starting point to review and tighten by hand — every generated field is optional (a default value alone can't tell you whether a field is actually required), and unrecognized default shapes fall back to :any rather than a guess.

Round-tripping a value with repeated structure, compactly

shared_address = %{"street" => "1 Main St", "city" => "Springfield"}
company = %{"hq" => shared_address, "billing" => shared_address, "shipping" => shared_address}

{:ok, compact} = Dextrin.encode_binary(company, share: true)
{:ok, ^company} = Dextrin.decode_binary(compact)

The three copies of shared_address are written once and referenced twice — transparently: the decoded result is an ordinary, independent value, ==-equal to what an unshared encoding would have produced.