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)
#=> trueShorthand 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`:
= 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}
= 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.