tramaj v5: numbers that compute, in all six implementations

On Thu, 15 Oct 2026, by @lucasdicioccio, 601 words, 1 code snippets, 9 links, 0images.

tramaj v5: numbers that compute, in all six implementations

tramaj has gone through four iterations so far: v1 (the original block/template split), v2 (expression-oriented documents, static analyses and the Node JSON format), v3 (symbols and constraints) and v4 (types). This is the fifth. It changes what a number is, and lets a template do something with one:

  • Two number types. An integer and a float, with no implicit conversion between them.
  • Arithmetic. Ten named builtins, round among them, in an optional profile that a host turns on.
  • Sorting. sort-by and sort-by-descending order a list by a key function.
  • Number formatting. format-number writes a number with a fixed number of decimals and a group separator, rounded the same way everywhere.

And the part that makes it a version rather than a design: all six implementations have all of it, and run the same corpus.

@rows=sort-by-descending($ctx.rows, (r) => $r.spend)
@total=sum(map($rows, (r) => $r.spend))
.table(
  map($rows, (r) => .tr(.td($r.name), .td(format-number($r.spend, 2, ","))))
  , .tr(.td("total"), .td(format-number($total, 2, ","))))

What changed since v4

Numbers. A JSON number in the context is typed by its text: 3 is an integer, 3.0 and 3e0 are floats. A template literal follows the same rule. Integers are exact over a guaranteed range; an integer outside it is refused instead of being rounded. Comparisons do not mix the two types, and real(…), floor(…) and round(…) convert when the template asks.

Arithmetic. Until v4 the reference said a template “compares and selects, it does not compute”. That is reversed, narrowly: the grammar has no infix operators, and arithmetic is ten named builtins (sum, product, negate, round, …) in a profile the host enables. A host that does not enable it can refuse such a program up front, from its static analysis, without evaluating it. The design and the alternatives that were rejected are in Two designs accepted.

Sorting and formatting. sort-by(list, fn) and sort-by-descending(list, fn) order a list by the key fn gives each element, and are stable. format-number(x, decimals, group) writes 1234567.891 as "1,234,567.89". Both are part of the core language and need no profile. The rounding rule of format-number is normative, because the native formatters of the six host languages disagree on ties and on negative zero: each implementation writes the rule by hand rather than calling its own formatter.

All of this is specified in reference.md §3, §11 and §13, and recorded, with the alternatives, in specs/decisions.md sections 18 and 20.

Breaking changes

The arithmetic profile is an addition: a template that does not use it is unaffected. The number split is not optional, and it changes the output of some existing templates and hosts:

whatv4v5
str of a whole-valued floatstr(1.0) is 1str(1.0) is 1.0
eq across the two typeseq(1, 1.0) is truefalse
lt, lte, gt, gte across the two typesgt(1.5, 0) is truea TypeMismatch
a JSON number in the contexta double, whatever its text3 is an integer, 3.0 and 3e0 are floats
an integer outside the rangerounded to the nearest doublerefused: a parse error as a literal, a TypeMismatch in the context
the object key "$term"an ordinary keyreserved, like "$sym" and "$type"
the type primitive number (v4 types)a primitivegone: int and float replace it

Migrating to integers and floats takes each row in turn: who it affects, how to find the templates it affects, and what to change.

Every implementation runs the whole corpus

The conformance corpus holds 646 cases: a template, a context and the expected output, compared byte for byte, in both concrete and symbolic mode. Until now an implementation could skip a case that needed a profile it did not provide, and while the ports caught up, some did. That is over: "int-float" and "arithmetic" are now required of every runner, and a case that lists one fails, rather than being skipped, in an implementation without it.

The only cases any implementation still skips are the ones written for the other integer range. PureScript and TypeScript have the guaranteed 53-bit range, because their host number is a double; the other four have the 64-bit range. Each skips the few cases that pin down the other range’s edges.

implementationinteger rangecorpus
tramaj (PureScript)53-bitpasses; skips only the 64-bit range cases
tramaj-hs (Haskell)64-bitpasses; skips only the 53-bit range cases
tramaj-rs (Rust)64-bitpasses; skips only the 53-bit range cases
tramaj-js (TypeScript)53-bitpasses; skips only the 64-bit range cases
tramaj-go (Go)64-bitpasses; skips only the 53-bit range cases
tramaj-py (Python)64-bitpasses; skips only the 53-bit range cases

Packages

Where to get v5 today:

packagelanguagewhere
tramaj-purs, tramaj-halogenPureScriptPureScript registry, 0.4.1
tramaj-cliPureScriptfrom the repository
tramaj-hsHaskellHackage, 0.4.0.0
tramaj-rs, tramaj-cli-rsRustcrates.io, 0.2.0
tramaj (tramaj-py)PythonPyPI, 0.2.0
@lucasdicioccio/tramaj-js, @lucasdicioccio/tramaj-reactTypeScriptnpm, 0.2.0
tramaj-goGofrom the repository, as github.com/lucasdicioccio/tramaj/tramaj-go, at a commit on main

Each package has its own changelog page and Atom feed. Integrating tramaj says how to depend on each one.

What’s next

v5 is a fifth iteration, not a stable release: the language can still change. The roadmap lists what is wanted next: folds to more rendering targets, and type definitions for the Python modules. Beyond v5, ?shape is an accepted design for allocating a structure of symbols in one form; no implementation has it yet.