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
| implementation | number split | arithmetic | integer range |
|---|---|---|---|
tramaj-purs, and tramaj-halogen, tramaj-cli and the playground built on it | yes | yes | -(2^53 - 1) to 2^53 - 1 |
tramaj-hs | yes | yes | signed 64-bit |
tramaj-js, tramaj-react | yes | yes | -(2^53 - 1) to 2^53 - 1 |
tramaj-rs, tramaj-cli-rs | yes | yes | signed 64-bit |
tramaj-go | yes | yes | signed 64-bit |
tramaj-py | yes | yes | signed 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
| what | before | now |
|---|---|---|
str of a whole-valued float | str(1.0) is 1 | str(1.0) is 1.0 |
eq across the two types | eq(1, 1.0) is true | false |
lt, lte, gt, gte across the two types | gt(1.5, 0) is true | a TypeMismatch |
| a JSON number in the context | a double, whatever its text | 3 is an integer, 3.0 and 3e0 are floats |
| an integer outside the range | rounded to the nearest double | refused: a parse error as a literal, a TypeMismatch in the context |
the object key "$term" | an ordinary key | reserved, like "$sym" and "$type" |
the type primitive number (v4) | a primitive | gone: 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:
| value | before | now |
|---|---|---|
1 | 1 | 1 |
1.0 | 1 | 1.0 |
1e5 | 100000 | 100000.0 |
1.5 | 1.5 | 1.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:
| PureScript | Haskell | |
|---|---|---|
| module | Tramaj.Json | Tramaj.Json |
| replaces | Data.Argonaut.Core.Json | Data.Aeson.Value |
| read text | jsonParser | jsonParser |
| write text | stringify | stringify |
| bridge | none: 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.
evalProgramandrunProgramkeep 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.
| where | how to turn it on |
|---|---|
tramaj-purs | runProgramWith (defaultOptions { arithmetic = true }) |
tramaj-hs | runProgramWith defaultOptions { optArithmetic = True } |
tramaj-js | runProgramWith({ mode: "concrete", arithmetic: true }, ...) |
tramaj-rs | run_program_with(&Options { mode, arithmetic: true }, ...) |
tramaj-go | tramaj.RunProgramWith(tramaj.Options{Mode: tramaj.Concrete, Arithmetic: true}, ...) |
tramaj-py | run_program(..., arithmetic=True) |
tramaj-cli, and the same flag on tramaj-cli-rs, tramaj-go and python -m tramaj | tramaj-cli --arithmetic template.tramaj context.json |
| playground | always 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
- Language reference, sections 3 (values), 5
(number literals), 6 (
str), 11 (comparisons and arithmetic) and 13 (the integer range). - Node JSON representation, Numbers.
- Symbols reference, section 1.9 (terms) and 5.3 (reserved keys).
decisions.md, section 18, for the reasoning and the alternatives that were rejected.