Migrating to integers and floats

On Thu, 08 Oct 2026, by @lucasdicioccio, 1053 words, 3 code snippets, 5 links, 0images.

Migrating to integers and floats

tramaj had one number type, a double. It now has two, an integer and a float, and nothing converts between them unless the template says so. The same change brings arithmetic, as named builtins: nine with the split, and a tenth, round, since.

Arithmetic is an addition: a host turns it on, and a template that does not use it is unaffected. The number split is not optional, and it changes what some existing templates and hosts produce. This page covers the split.

Who is affected

implementationnumber splitarithmeticinteger range
tramaj-purs, and tramaj-halogen, tramaj-cli and the playground built on ityesyes-(2^53 - 1) to 2^53 - 1
tramaj-hsyesyessigned 64-bit
tramaj-js, tramaj-reactyesyes-(2^53 - 1) to 2^53 - 1
tramaj-rs, tramaj-cli-rsyesyessigned 64-bit
tramaj-goyesyessigned 64-bit
tramaj-pyyesyessigned 64-bit

All six implementations have the change, on the main branch and in no release yet. A host that follows main as a git dependency gets it on its next update. A host on a published package (tramaj-rs or tramaj-cli-rs 0.1.0 from crates.io, tramaj 0.1.0 from PyPI) still has a single number type, and changes the same way when it moves to the next release.

The changes at a glance

whatbeforenow
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)a primitivegone: int and float replace it

1. A whole-valued float is written with a fraction

A number keeps its type when it is written. An integer is written as its digits. A float always carries a fraction or an exponent, so a float with no fractional part gets .0:

valuebeforenow
111
1.011.0
1e5100000100000.0
1.51.51.5

This applies to str, to string interpolation, and to every number in the JSON an evaluation returns: text values, attribute values, action payloads, value slots, and an expression program’s result.

What to do. Look for float literals that are whole (1.0, 1e5) and for context numbers a producer writes with a fraction ("ratio": 2.0). Where the text matters and the value is a count, make it an integer: write the literal 100000, or have the producer write 2. An integer renders exactly as before.

A consumer that reads the output with an ordinary JSON parser sees the same numeric value for 3 and 3.0. Only a consumer that compares the output as text, or that decodes it back into a Node, sees the difference.

2. Comparisons do not mix the two types

eq never equates an integer with a float, and the four ordering comparisons take two integers or two floats:

eq(1, 1.0)         -- false, was true
gt($ctx.ratio, 0)  -- TypeMismatch when ratio is a float

The first of these is silent: a branch or a filter that compared a context number with a literal of the other type now takes the other arm.

What to do. Write the literal in the type of what it is compared with: gt($ctx.ratio, 0.0) for a float, eq($ctx.count, 0) for an integer.

When the template does not know which type its host sends, and the implementation has arithmetic turned on, normalize with one of the two conversions. Each accepts both types:

gt(real($ctx.ratio), 0.0)   -- real: the float nearest to its argument
eq(floor($ctx.count), 0)    -- floor: the largest integer not above it

3. A JSON number is typed by its text

A number in the context is read as the literal of the same text. With neither a fraction nor an exponent it is an integer; with either it is a float. So {"n": 3} and {"n": 3.0} are two different contexts.

Two things follow for a host.

The producer of the context decides the type. A serializer with a single number type writes the float 3.0 as 3, and the template then receives an integer. If a template needs a float from such a producer, it normalizes with real, as above.

The host’s JSON parser must keep the difference. JavaScript’s JSON.parse and Haskell’s aeson Value both lose it. The PureScript and Haskell implementations therefore ship their own JSON value, and their public API takes it in place of the previous one:

PureScriptHaskell
moduleTramaj.JsonTramaj.Json
replacesData.Argonaut.Core.JsonData.Aeson.Value
read textjsonParserjsonParser
write textstringifystringify
bridgenone: build values with fromInt, fromFloat, …fromAeson, toAeson

The Haskell bridge cannot recover what aeson dropped. fromAeson makes every whole number that fits 64 bits an integer, and toAeson gives the type up again. Read the JSON text with jsonParser when the difference matters.

The other four implementations changed the same way, each with a reader that types a number by its text: tramaj_rs::json::Json in place of serde_json::Value in Rust, Float and parseJson in TypeScript, tramaj.JSON with int64 and float64 in Go, and int and float with parse_json in Python. Integrating tramaj has the details for each.

Numbers that are not values are refused. An integer-form number outside the implementation’s integer range is not rounded any more: it is a parse error as a literal and a TypeMismatch in the context. So is a float too large for a double, such as 1e400. The context is checked whole before evaluation starts, so the error does not depend on whether the template reads that number. -0 and -0.0 are read as zero.

The integer range differs between implementations (see the table at the top). Inside -(2^53 - 1) to 2^53 - 1 they agree. A larger integer that a 64-bit implementation emits is refused by the PureScript and TypeScript ones.

4. "$term" is a reserved key

Over a symbol, an arithmetic builtin does not compute. It evaluates to a term, written as an object with the keys "$term" and "arguments". To keep that unambiguous, "$term" joins "$sym" and "$type" as a reserved key, whether or not arithmetic is turned on:

  • an object literal with the key "$term" in a template is a parse error;
  • a context that carries the key is a TypeMismatch. The one exception is a well-formed term seeded in symbolic mode, with arithmetic turned on.

What to do. Rename the key in any data that happens to use it.

5. number is no longer a type (v4)

In a type annotation, int and float replace the primitive number:

@n : int = 3        -- was: @n : number = 3
@r : float = 0.5

number is now an unresolved type name. A host checker that received {"$type": "number"} receives {"$type": "int"} or {"$type": "float"}.

What does not change

  • The grammar. There are no new operators and no new syntax.
  • A template that uses only integers, and whose context holds only numbers written without a fraction or an exponent, evaluates to the same output.
  • evalProgram and runProgram keep their signatures, apart from the JSON type above. They run without arithmetic.

Arithmetic is separate, and off by default

The ten builtins (sum, product, negate, quotient, inverse, floor-quotient, modulo, floor, real, round) are an optional profile that a host chooses per evaluation. Without it the names are unbound, so an existing template that binds its own sum or floor keeps working either way: a binding shadows the builtin.

wherehow to turn it on
tramaj-pursrunProgramWith (defaultOptions { arithmetic = true })
tramaj-hsrunProgramWith defaultOptions { optArithmetic = True }
tramaj-jsrunProgramWith({ mode: "concrete", arithmetic: true }, ...)
tramaj-rsrun_program_with(&Options { mode, arithmetic: true }, ...)
tramaj-gotramaj.RunProgramWith(tramaj.Options{Mode: tramaj.Concrete, Arithmetic: true}, ...)
tramaj-pyrun_program(..., arithmetic=True)
tramaj-cli, and the same flag on tramaj-cli-rs, tramaj-go and python -m tramajtramaj-cli --arithmetic template.tramaj context.json
playgroundalways on

tramaj-cli analyze arithmetic template.tramaj lists the arithmetic names a template and its libraries reference, without running anything. A host without the profile can use it to refuse a template up front.

Where the rules are written