Dextrin

Warning

Under heavy development: not for production use before 1.0.0. Things can and will break, and the specification can and will change, between any two releases until 1.0.0. Do not use this library in a production system yet.

CI Hex.pm Documentation Docs (main)

Dextrin is an Elixir implementation of DXN (Data eXchange Notation): a human-writable text format (.dxn), a compact binary format (.dxnb) built on CBOR, and a schema format (.dxns) that's just more DXN data — all three sharing one in-memory value representation and one extension mechanism.

This is the reference implementation. node-dextrin (Node.js, on npm) and php-dextrin (PHP, on Packagist) are sibling ports of the same format — see Other language implementations below.

{:ok, value} = Dextrin.decode(~s(%{x: 1, y: 2}), trusted: true)
value
#=> %{x: 1, y: 2}

Dextrin.encode(value)
#=> {:ok, "%{x:1,y:2}"}

{:ok, bytes} = Dextrin.encode_binary(value)
Dextrin.decode_binary(bytes, trusted: true)
#=> {:ok, %{x: 1, y: 2}}

Decoding is safe by default. A plain map's shorthand keys (x:) are themselves DXN keywords — %{x: 1} and %{:x => 1} are the exact same value — and a keyword decodes as Dextrin.Keyword.t(), never an atom, so no input can exhaust the atom table:

Dextrin.decode(~s(%{x: 1, y: 2}))
#=> {:ok, %{%Dextrin.Keyword{name: "x"} => 1, %Dextrin.Keyword{name: "y"} => 2}}

Pass trusted: true for a source you control — your own config, your own application's data — and a keyword decodes as a real atom, its own Elixir type per DXN.md §1.3, as in the first example. Like Jason's keys: :atoms, it is opt-in; never pass it for network input. Encoding takes a bare atom anywhere a keyword is expected either way — %{x: 1} or Dextrin.encode(:ok) need no conversion first.

A schema-backed struct's fields are the one place names do come back as plain strings regardless of trusted: — see the tutorial — since a schema always knows its field names up front.

.dxn's text grammar is compiled by Ichor — write the grammar once (priv/grammar/dxn.aether), get a lexer, parser, and (via Dextrin.Text.Actions) an evaluator with no hand-written parsing code. That compilation happens ahead of time (mix ichor.gen, checked in as generated source), not at dextrin's own build time, so only the small ichor_runtime support library the generated code actually calls ships as a real dependency — ichor proper (the Aether front-end, analysis, codegen) is dev-tooling only. .dxnb has no grammar to speak of — it's a direct, hand-rolled CBOR codec — so it's plain recursive Elixir working over the same shared value type.

Why use Dextrin?

Most serialization formats pick one point on a spectrum. JSON is human-writable but has no dates, no bytes, no exact decimals and no boundary between an integer and a float. Protobuf and Avro are compact and typed but need a schema compiled ahead of time, and aren't meant for a person to read or edit. CBOR and MessagePack are compact and self-describing but binary only. YAML and TOML are made for people, and have no binary form for machines.

DXN's premise is that a .dxn text document and a .dxnb binary document are the same value space: 31 scalar, collection, temporal and extended types (reference §1.3). It is precise enough for money (decimals), exact ratios (rationals) and integers of any size, has struct and custom-tag as first-class, schema-describable extension points, and its schemas (.dxns) are DXN too. You choose text or binary by what matters where the data is going -- a person reading it, or bytes on the wire -- and the value is the same either way.

What each format can say

DXN JSON YAML TOML BSON MessagePack CBOR Protobuf Ion
Text a person writes ✓ ✓ ✓ ✓ – – –¹ –² ✓
Binary, same values ✓ – – – ✓ ✓ ✓ ✓ ✓
Comments ✓ – ✓ ✓ – – – – ✓
Schema, part of the format ✓ ~³ – – – – – –² ~³
Readable without a schema ✓ ✓ ✓ ✓ ✓ ✓ ✓ – ✓
Integer and float kept apart ✓ – ✓ ✓ ✓ ✓ ✓ ✓ ✓
Integers beyond 64 bits ✓ ~⁴ ~⁴ – – – ✓ – ✓
Exact decimals ✓ – – – ✓ – ✓ – ✓
Exact ratios ✓ – – – – – ✓ – –
Timestamps, dates, times, durations ✓ – ~⁵ ~⁶ ~⁷ ~⁷ ~¹³ ~⁸ ~¹⁴
Bytes ✓ – ~⁵ – ✓ ✓ ✓ ✓ ✓
UUIDs, URIs ✓ – – – ~⁹ – ✓ – –
Sets, tuples ✓ – ~⁵ – – – ~¹⁰ – –
Map keys that are not strings ✓ – ✓ – – ✓ ✓ ~¹¹ –
Typed records (structs) ✓ – – – – – – ✓ –
Types an application adds ✓ – ✓ – – ✓ ✓ – ✓
Versioned schemas, checked in CI ✓ – – – – – – ~¹² –

¹ CBOR has a diagnostic notation for reading, not for authoring. ² Protobuf's text format is for debugging; its schema is a separate .proto file compiled ahead of time. ³ JSON Schema and Ion Schema are written in JSON and Ion, as separate specifications. ⁴ The grammar allows any size; JavaScript's and PHP's parsers lose the digits past 53 and 64 bits. ⁵ YAML 1.1 tags (!!timestamp, !!binary, !!set), dropped from 1.2's core schema. ⁶ Dates, times and date-times; no durations. ⁷ An instant only: no local dates, times or durations. ⁸ The well-known Timestamp and Duration messages. ⁹ A UUID as binary subtype 4. ¹⁰ Sets by registered tag 258; no tuples. ¹¹ Integer and string keys only. ¹² Field numbers keep old readers working; nothing checks a change. ¹³ Instants (tags 0 and 1) and dates (RFC 8943); no times of day or durations. ¹⁴ Timestamps of any precision, dates included; no times of day or durations.

How big it is

The same four payloads in each format, in bytes, raw and gzipped. Reproduce it with mix run bench/format_sizes.exs; the script says how each format writes the values it has no type for (a decimal or a date as a string in JSON, for instance).

config file API response, 100 records time series, 1,000 points record with a 4 KiB blob
DXN text 279 / 199 19,402 / 4,218 63,159 / 13,895 5,516 / 4,222
DXN text, pretty 314 / 216 23,504 / 4,249 67,161 / 13,961 5,527 / 4,234
.dxnb 264 / 210 14,185 / 3,802 34,006 / 14,381 4,145 / 4,168
.dxnb, shared 264 / 210 9,217 / 3,752 27,017 / 14,019 4,145 / 4,168
JSON 306 / 207 19,202 / 4,201 56,159 / 13,692 5,514 / 4,216
JSON, pretty 403 / 229 26,253 / 4,287 74,160 / 14,182 5,527 / 4,231
BSON 312 / 231 17,621 / 4,448 36,903 / 15,700 4,161 / 4,184
MessagePack 237 / 199 12,271 / 3,840 25,003 / 11,165 4,136 / 4,159
CBOR 237 / 188 12,282 / 3,514 25,003 / 11,164 4,136 / 4,159
Protocol Buffers 141 / 111 8,552 / 3,702 19,000 / 10,832 4,122 / 4,145

How fast it is

Decoding a document from text and checking it against a schema, timed in each implementation on the same data sets: one Universe each, about 4 KB of DXN text, against bench/schemas/universe.dxns, a 225-line schema set more complex than any one met in practice (recursion, generics, cases, refinements, formats, documents inside documents). About 30% of the data sets carry exactly one error, of 44 kinds. Each implementation ran for 90 minutes on one core of an Apple M2 Max (Elixir 1.19.5 on OTP 28 with ichor 0.7.3's parser, 2026-10-26; Node 24.18.1 and PHP 8.4.24 without opcache, 2026-10-22), and every verdict matched what the data set was meant to be. Times are per data set, in milliseconds.

data sets decode, mean decode, p99 check, mean check, p99 together, mean together, p99
Elixir 713,050 3.45 7.74 0.16 0.93 3.60 7.97
Node 4,698,138 0.53 0.85 0.54 1.02 1.07 1.80
PHP 1,316,837 1.95 2.89 0.83 1.57 2.78 4.37

Reproduce it with mix run bench/schema_validation.exs 5400; the Node and PHP numbers come from their own benchmarks, bench/schema-validation.cjs and bench/schema-validation.php, reading the very same data sets from bench/generate_universes.exs.

What comes back, in each language

What a value is after a round trip through each language's own JSON parser, and through Dextrin:

Written Elixir: JSON / Dextrin Node.js: JSON / Dextrin PHP: JSON / Dextrin Praxis: JSON / Dextrin
9223372036854775808 integer / integer 9223372036854776000 / BigInt float / DXNInteger integer / integer
19.99 as an exact decimal float / Decimal float / DXNDecimal float / DXNDecimal float / dextrin.decimal
1.0 float / float 1, an integer / float float, written back as 1 / float float / float
a datetime string / DateTime string / DXNDateTime string / DXNDateTime string / a praxis.time instant
bytes base64 string / Dextrin.Bytes base64 string / DXNBytes base64 string / DXNBytes base64 string / dextrin.bytes
a map keyed by integers string keys / integer keys string keys / integer keys string keys / integer keys string keys / integer keys
a set list / MapSet array / Set array / DXNSet vector / set

A bold entry is a value that did not come back as what was written. With Dextrin, the same document means the same thing in all four, and every implementation is held to that by the Dextrin Conformance Suite.

How DXNS compares, as a schema language

A .dxns schema is DXN data. It is meant to say everything the schema languages of the formats above can say, except what has no value or no meaning in DXN (design/schema-superset.md goes through them construct by construct). YAML, TOML and MessagePack have no schema language of their own; people use JSON Schema for them.

DXNS JSON Schema MongoDB $jsonSchema CDDL Protobuf Ion Schema
Written in the format it describes ✓ ✓ ✓ – – ✓
Typed records, with versions ✓ – – – ~¹ –
Version changes checked in CI ✓ – – – ~¹ –
Recursive types ✓ ✓ – ✓ ✓ ✓
Generic types ✓ ~² – ✓ – –
A list's items in order ✓ ✓ ✓ ✓ – ✓
A type chosen by a sibling field ✓ ~³ – ~⁴ ~⁵ –
String formats, checked the same everywhere ✓ ~⁶ – ~⁷ ~⁸ ~⁷
Decimals, ratios, dates, times, durations as types ✓ – ~⁹ ~¹⁰ – ~¹¹
Bytes holding an encoded document ✓ ✓ – ✓ – –
Annotations and free metadata ✓ ✓ ~¹² – ✓ ✓
Closing a type to unknown fields –¹³ ✓ ✓ ✓ – ✓
One test suite every implementation passes ✓ ~⁶ – – – –

¹ Field numbers keep old readers working; checking a change takes a tool outside the language, such as buf breaking. ² $dynamicRef, since draft 2020-12. ³ if/then/else; OpenAPI adds a discriminator on top. ⁴ A group choice, //, over maps with a literal member. ⁵ oneof picks one field of several, not the type of one. ⁶ The official test suite exists, but format only annotates unless a validator opts in, and each implements the formats its own way. ⁷ Regular expressions (CDDL's .regexp, Ion Schema's regex); CDDL's .abnf takes a grammar. ⁸ Through the separate protovalidate rules. ⁹ decimal and date BSON types; no times of day or durations. ¹⁰ Through tags and the prelude (tdate, decfrac); no durations. ¹¹ decimal and timestamp, with precision and offsets; no durations. ¹² title and description only. ¹³ On purpose: see the next section.

Why a DXNS type is never closed

JSON Schema's additionalProperties: false, Ion Schema's closed and CDDL's maps refuse a field the schema does not list. DXNS has no such switch, for two reasons:

A check that needs more -- say, refusing exactly the fields another schema allows -- runs after the decode, on the value it returns, where the application knows what it means.

Changing a schema across services

Several services reading and writing the same schema never all deploy at once. DXNS makes that safe with versions, not with compatibility modes (DXNS.md §8):

So a change deploys like this:

  1. A compatible change -- fields appended, nothing else -- deploys in either order.
  2. A breaking change -- a field removed, moved or retyped -- deploys readers first, each with a materializer for the old version, then the writers.
  3. A schema that services both read and write changes only compatibly, or is replaced by a new name (OrderV2) beside the old one, and the services move over one at a time.

Components

Installation

Add dextrin to your list of dependencies in mix.exs:

def deps do
  [
    {:dextrin, "~> 0.3.3"}
  ]
end

Other language implementations

DXN's format spec (guides/dxn/DXN.md) is implementation-independent — this library, node-dextrin, and php-dextrin all satisfy the same document, and stay close to this project's own in-memory shape-per-type choices wherever their host language allows, so recognizing one implementation gets you most of the way to recognizing the others.

Language Package Source
Elixir (this project) dextrin on Hex.pm joetjen/dextrin
Node.js @joetjen/dextrin on npm joetjen/node-dextrin
PHP joetjen/dextrin on Packagist joetjen/php-dextrin

Where to go next

Development

mix deps.get
mix precommit

mix precommit runs the full verification pass this project expects before a commit: mix format, mix compile --warnings-as-errors, mix credo --strict, mix sobelow, mix test, and mix dialyzer, in that order (fast/cheap checks first, dialyzer — the slowest, especially its first PLT build — last).

priv/grammar/dxn.aether's generated Unicode identifier ranges are regenerated with mix dextrin.gen.unicode — a deliberate, reviewed action on a Unicode version bump, never run automatically at build time (see that task's own docs). Either way, changing the grammar itself requires a mix ichor.gen step afterward, since lib/dextrin/text/grammar/native.ex is generated ahead of time, not produced at dextrin's own compile time (that's also what keeps ichor itself, and everything it depends on for parsing/codegen, only: :dev, runtime: false — only the small ichor_runtime package ships in a release) — see CONTRIBUTING.md.

Two benchmarks live in bench/: mix run bench/format_sizes.exs measures the sizes in the comparison above, and mix run bench/schema_validation.exs [seconds] [seed] times decoding -- lexing, parsing, building -- and Dextrin.Schema.check/3, data set by data set for ten minutes, against a schema set more complex than any real one (bench/schemas/universe.dxns: recursion, generics, cases, documents inside documents), about 30% of the data sets carrying one error. It reports each stage's total, mean, minimum, median, percentiles and maximum, good against bad and by kind of error, and writes the fastest, slowest and average data sets to tmp/bench/schema-validation/. bench/generate_universes.exs writes the same data sets for the Node and PHP implementations' benchmarks.

See CONTRIBUTING.md for how to propose changes, and CHANGELOG.md for release history.

License

Apache-2.0 — see LICENSE.