A step-by-step walkthrough of dextrin's features, building up to a
small, complete example: decoding a config file, validating it against
a schema, and re-encoding it to both text and binary. If you want the
DXN format itself explained independently of this Elixir
implementation, see the DXN tutorial instead — this
one assumes you already roughly know what .dxn text looks like and
focuses on the Elixir API around it.
1. Decoding and encoding text
{:ok, value} = Dextrin.decode(~s(%{name: "Ada", active: true, score: 19.99M}))
#=> {:ok, %{active: true, name: "Ada", score: Decimal.new("19.99")}}
Dextrin.encode(value)
#=> {:ok, "%{active:true,name:\"Ada\",score:19.99M}"}Notice the map's keys: a plain map's shorthand keys (name:) are
themselves DXN keywords, not bare strings — %{name: "Ada"} and
%{:name => "Ada"} are the exact same value, and keyword's own
Elixir type (DXN.md §1.3) is a real atom, which is what decode/2
produces by default (trusted: true, since Dextrin.Registry.new/0's
default). Pass trusted: false for input you don't fully control —
untrusted .dxn/.dxnb must never be able to exhaust the atom table
via an unbounded String.to_atom/1 — and keyword decodes as
Dextrin.Keyword.t() instead:
Dextrin.decode(~s(%{name: "Ada"}), trusted: false)
#=> {:ok, %{%Dextrin.Keyword{name: "name"} => "Ada"}}A schema-backed struct's fields are the one place names do come
back as plain strings regardless of trusted: (§6 below), since a
schema always knows its field names up front.
Both directions return {:ok, _} | {:error, %Dextrin.Error{}} —
decode/2 never raises on malformed input, and encode/2 never
raises on a value it can't represent. Dextrin.Error.format/1 renders
either kind of error, including caret-annotated source context for
text-side syntax errors.
{:error, error} = Dextrin.decode("%{x: }")
IO.puts(Dextrin.Error.format(error))2. What a decoded value looks like
Every DXN type maps to a plain Elixir value where one exists, and to a small wrapper struct only where nothing native fits without losing information:
{:ok, sym} = Dextrin.decode("some-symbol")
#=> {:ok, %Dextrin.Symbol{name: "some-symbol"}}
{:ok, kw} = Dextrin.decode(":ok")
#=> {:ok, :ok}
{:ok, tup} = Dextrin.decode("{1 2 3}")
#=> {:ok, %Dextrin.Tuple{items: [1, 2, 3]}}
{:ok, om} = Dextrin.decode("@ordered %{b: 2, a: 1}")
#=> {:ok, %Dextrin.OrderedMap{pairs: [{:b, 2}, {:a, 1}]}}symbol always wraps a plain String.t(), never an Elixir atom —
decoding untrusted DXN data can never be used to exhaust the atom
table through a symbol, trusted: or not. keyword is the one type
with two faces, controlled by trusted: (default true): a real
atom, as shown above, or Dextrin.Keyword.t() when decoded untrusted
(trusted: false) — see Dextrin.Registry.put_trusted/2's own doc.
See the module docs under Dextrin.Value (the full type union) and
each wrapper module for more.
3. Round-tripping through .dxnb
{:ok, bytes} = Dextrin.encode_binary(value)
{:ok, ^value} = Dextrin.decode_binary(bytes).dxnb is CBOR underneath, with a 3-byte envelope ("DX" + a version
byte) in front for cheap magic-number sniffing. Pass share: true to
encode_binary/2 to opt into DXN's value-sharing extension — repeated
compound values get written once and referenced afterward, when the
byte-count math actually favors it:
big_map = %{"k" => List.duplicate(%{"a" => 1, "b" => 2}, 100)}
{:ok, small} = Dextrin.encode_binary(big_map, share: true)
{:ok, large} = Dextrin.encode_binary(big_map, share: false)
byte_size(small) < byte_size(large)
#=> true4. Formatting
Dextrin.encode/2 defaults to the smallest text a value can
round-trip through: single-line, minimal whitespace, no line-wrapping
or indentation at all. Pass pretty: true for human-readable,
multi-line output instead — e.g. for a CLI or a config file you're
about to commit:
{:ok, value} = Dextrin.decode(~s(%{name: "Ada", tags: @{:admin :staff}}))
Dextrin.encode(value, pretty: true)
#=>
# {:ok, "%{
# name: \"Ada\"
# tags: @{
# :admin
# :staff
# }
# }"}indent: sets the number of spaces per nesting level (default 2):
Dextrin.encode(value, pretty: true, indent: 4)
#=> {:ok, "%{\n name: \"Ada\"\n tags: @{\n :admin\n :staff\n }\n}"}pretty/indent only change rendering — the value decode/2 gets
back is identical either way. Schema validation (§6 below) runs the
same regardless too; it's an independent concern from formatting.
pretty: true calls Dextrin.Text.Formatter.pretty/2 under the hood,
which is also directly callable on its own (mix dextrin.format's
--mode pretty uses it that way, without going through encode/2's
validation).
Comments are never preserved by either path — .dxn's lexer discards
#-comments as trivia before the parser ever sees them, so there's no
comment text left by the time a value exists to reprint.
5. Extending: custom tags
@tag value is DXN's open extension point for a scalar-wrapping type
with no field structure. Register a decoder and (optionally) an
encoder on a Dextrin.Registry:
defmodule MyApp.Money do
defstruct [:cents]
end
registry =
Dextrin.Registry.new()
|> Dextrin.Registry.put_tag("my-app/money", fn cents -> {:ok, %MyApp.Money{cents: cents}} end)
|> Dextrin.Registry.put_tag_encoder(MyApp.Money, "my-app/money", fn %MyApp.Money{cents: c} -> {:ok, c} end)
Dextrin.decode(~s(@my-app/money 500), registry: registry)
#=> {:ok, %MyApp.Money{cents: 500}}
Dextrin.encode(%MyApp.Money{cents: 500}, registry: registry)
#=> {:ok, "@my-app/money 500"}With no registration at all, @my-app/money 500 decodes to an opaque
%Dextrin.CustomTag{name: "my-app/money", value: 500} instead of
failing — the same "opaque tagged value, not an error" contract DXN
gives every unrecognized tag.
6. Extending: schemas for struct
struct is DXN's field-structured extension point, and it's
schema-dependent by design: without a compiled schema, %Point{x: 1, y: 2} decodes to an opaque %Dextrin.Struct{}. A .dxns schema
document is itself just DXN data — no new grammar, no new parser:
{:ok, schema_doc} = Dextrin.decode("""
%{
Point: %schema{
fields: @ordered %{ x: :integer, y: :integer }
}
}
""")
{:ok, registry} = Dextrin.Schema.compile(schema_doc)
Dextrin.decode("%Point{x: 1, y: 2}", registry: registry)
#=> {:ok, %{"x" => 1, "y" => 2}}Field enforcement (required/optional, closed/forbidden fields,
refinements) happens automatically at decode time, in both decode/2
and decode_binary/2 — a violation comes back as an ordinary
{:error, %Dextrin.Error{}}, indistinguishable by shape from a syntax
error:
Dextrin.decode("%Point{x: 1}", registry: registry)
#=> {:error, %Dextrin.Error{message: "struct \"Point\" violates its schema: missing required field \"y\"", ...}}Encoding checks the same thing, automatically, in the other direction
— every Dextrin.Struct (or registered application struct) anywhere
in the value you're encoding gets checked against its own schema
before anything is written:
bad = Dextrin.Struct.keyed("Point", [{"x", 1}])
Dextrin.encode(bad, registry: registry)
#=> {:error, %Dextrin.Error{}} # missing required field "y"Pass validate: false to skip this (test fixtures, deliberately
building non-conforming data, a pass-through that shouldn't
second-guess data it isn't the origin of).
7. Materializing real Elixir structs
By default a struct materializes to a plain string-keyed map. Register a materializer to produce your own struct instead:
defmodule MyApp.Point do
defstruct [:x, :y]
end
registry =
registry
|> Dextrin.Registry.put_struct_materializer("Point", fn %{x: x, y: y} ->
{:ok, %MyApp.Point{x: x, y: y}}
end)
Dextrin.decode("%Point{x: 1, y: 2}", registry: registry)
#=> {:ok, %MyApp.Point{x: 1, y: 2}}To let {:reference, "Point"} type-checks and automatic encode-time
validation recognize %MyApp.Point{} values you build yourself
(never decoded), also declare the module:
registry = Dextrin.Registry.put_struct_module(registry, "Point", MyApp.Point)This also lets you encode a %MyApp.Point{} directly — no need to
hand-build a Dextrin.Struct first:
Dextrin.encode(%MyApp.Point{x: 1, y: 2}, registry: registry)
#=> {:ok, "%Point{x:1,y:2}"}8. Named types and the standard library
Any .dxns entry that isn't a %schema{} defines a reusable named
type instead — purely data, composing the fixed type_expr vocabulary:
{:ok, doc} = Dextrin.decode("""
%{
PositiveInt: {:refine :integer %{min: 1}}
Point: %schema{ fields: @ordered %{ x: PositiveInt, y: :integer } }
}
""")Dextrin.Schema.Std ships a small library of common ones
(PositiveInteger, NonEmptyString, Percentage, ...) — pass its
registry as compile/3's base_registry to use them:
{:ok, registry} = Dextrin.Schema.compile(doc, Dextrin.Schema.Std.registry())9. Letting a third-party struct provide its own schema
Everything so far assumed you write the .dxns schema yourself. If
MyApp.Point instead came from a library that doesn't want to (and
shouldn't have to) depend on dextrin, that library can ship a small,
separately-compiled companion module implementing
Dextrin.Schema.Provider — guarded behind an optional dependency, so
the struct's own module is never conditionally compiled:
# the library's own mix.exs: {:dextrin, "~> 0.1", optional: true}
if Code.ensure_loaded?(Dextrin.Schema.Provider) do
defmodule MyLib.Point.DXN do
@behaviour Dextrin.Schema.Provider
@impl true
def dxn_schema, do: """
%{ Point: %schema{ fields: @ordered %{ x: :integer, y: :integer } } }
"""
@impl true
def dxn_schema_name, do: "Point"
@impl true
def dxn_struct, do: MyLib.Point
@impl true
def dxn_materialize(%{x: x, y: y}), do: {:ok, %MyLib.Point{x: x, y: y}}
end
endAn application depending on both my_lib and dextrin registers it
in one call, wherever it's already building its registry:
{:ok, registry} = Dextrin.Schema.register_provider(Dextrin.Registry.new(), MyLib.Point.DXN)
Dextrin.decode("%Point{x: 1, y: 2}", registry: registry)
#=> {:ok, %MyLib.Point{x: 1, y: 2}}
Dextrin.encode(%MyLib.Point{x: 1, y: 2}, registry: registry)
#=> {:ok, "%Point{x:1,y:2}"}dxn_materialize/1 is optional — without it, decoding falls back to
the same plain field map any other schema with no materializer
produces. See Dextrin.Schema.Provider's own moduledoc for why this
is a companion module rather than the struct's own, and for reading
dxn_schema/0's source from a file at compile time instead of an
inline string.
10. Putting it together: a small config loader
defmodule MyApp.ConfigLoader do
@schema """
%{
Server: %schema{
fields: @ordered %{
host: NonEmptyString
port: {:refine :integer %{min: 1, max: 65535}}
tags?: {:list-of :symbol}
}
}
}
"""
def registry do
{:ok, doc} = Dextrin.decode(@schema)
{:ok, registry} = Dextrin.Schema.compile(doc, Dextrin.Schema.Std.registry())
registry
end
def load(path) do
with {:ok, source} <- File.read(path) do
Dextrin.decode(source, registry: registry())
end
end
endNo separate validation step is needed here — registry() already has
Server's schema compiled into it, so Dextrin.decode/2 itself
enforces every field, automatically, as part of decoding (§6). A
malformed config file comes back as an ordinary {:error, %Dextrin.Error{}} from load/1 directly, not something the caller
has to separately check for.
# config.dxn
%Server{
host: "localhost"
port: 4000
tags: [dev local]
}MyApp.ConfigLoader.load("config.dxn")
#=> {:ok, %{"host" => "localhost", "port" => 4000, "tags" => [%Dextrin.Symbol{name: "dev"}, %Dextrin.Symbol{name: "local"}]}}From here: Examples for more worked scenarios, the Cheatsheet for quick lookups, and the DXN reference for the full format specification.