Numbers

Mix has one numeric type: a 64-bit IEEE-754 double (f64). There is no separate integer type — 5, 5.0, 1e6, 0xFF, and 0o755 are all the same Number value under the hood. What makes Mix feel like it has integers is the printer: a number that is exactly representable as an i64 is written without a decimal point (5, not 5.0). This page covers that f64 model, integer-clean formatting, the 0o/0x/0b radix literals (and why a bare leading zero is a lexer error), scientific-notation literals, strict numeric-string coercion, NaN/inf behaviour, the ordering rules, and how whole numbers cross serialization boundaries as integers.

For the numeric functions (round, floor, sqrt, pow, abs, …) see math. For the comparison and arithmetic operators see operators.

One-line model: every number is an f64; it prints as an integer when it round-trips through i64; 0o/0x/0b and 1e6 are sugar for the same f64; a bare leading zero is rejected.

One type: f64

print(2 + 2)        -- 4
print(10 / 2)       -- 5      (whole result, prints clean)
print(7 / 2)        -- 3.5    (fractional, prints with a point)
print(10 / 4)       -- 2.5
print(2.5)          -- 2.5
print(1.0)          -- 1      (1.0 round-trips through i64 → printed as 1)
print(-5)           -- -5
print(17 % 5)       -- 2      (modulo)
4
5
3.5
2.5
2.5
1
-5
2

Because the type is f64, integer division is not a separate operation — / is always float division and 10 / 2 happens to land on a whole number. There is no // floor-division operator; use floor if you want it.

f64 caveats apply

Mix inherits every IEEE-754 wrinkle. Fractions are binary, so the classic rounding error is visible:

print(0.1 + 0.2)    -- not exactly 0.3
0.30000000000000004

Integers stay exact only up to 2^53 (9_007_199_254_740_992). Past that, even whole numbers lose precision:

print(9007199254740992)   -- 2^53, exact
print(9007199254740993)   -- 2^53 + 1, rounds back down
9007199254740992
9007199254740992

That 2^53 boundary is why stat returns inode/device numbers as strings, not numbers — a u64 inode above 2^53 would lose its low bits as an f64.

Division and modulo by zero are runtime errors (not inf/NaN), but a math function can still produce a non-finite value:

print(1 / 0)        -- runtime error
Runtime error at line 1: division by zero
print(5 % 0)        -- runtime error too (0.21+; it used to return NaN silently)
Runtime error at line 1: modulo by zero
print(sqrt(-1))     -- NaN (from math, not an error)
print(ln(0))        -- -inf
print(exp(1000))    -- inf (overflow)
NaN
-inf
inf

Once a non-finite value exists it propagates through arithmetic per IEEE-754 (exp(1000) + 1 is inf, 1 / exp(1000) is 0) and prints as NaN / inf / -inf. Comparisons involving a NaN value follow IEEE-754 too — they are false, not errors:

print(sqrt(-1) == sqrt(-1))   -- false (NaN is never equal to anything)
print(sqrt(-1) < 5)           -- false (any ordering against NaN is false)
false
false

Integer-clean printing

When Mix renders a number — via print, ${...} interpolation, .. concatenation, or inside a list/map — it uses an i64 round-trip gate: if (n as i64) as f64 == n, it prints the i64 form (no decimal point); otherwise it prints the full f64 (value.rs::write_mix). The gate is the exact i64 cast, not merely "is it integral", so a value too big for i64 (like 1e20) still prints in f64 form instead of silently saturating. (One pinhole: exactly 2^63 passes the gate through the saturating cast and prints as 9223372036854775807 — one below the true f64 value. Anything past 2^53 is approximate territory anyway.)

Output is always plain decimal — the printer never uses exponent form, even for values you wrote in scientific notation: print(1e20) gives 100000000000000000000, print(1e-10) gives 0.0000000001.

print(4.0 / 2.0)          -- 2     (whole → integer form)
print(3.0 * 3.0)          -- 9
$n = 5
print("count is ${n}")    -- interpolation: clean
print("id-" .. $n)        -- concat: clean
$f = 2.5
print("val ${f}")         -- fractional stays fractional
2
9
count is 5
id-5
val 2.5

Every render path shares this gate, so a number looks the same whether you print it, splice it into a string, or nest it in a structure.

Radix literals — 0o / 0x / 0b

For file modes and bitmasks, write the value in its natural base. All three prefixes are pure sugar that lex to the same f64 (lexer.rs::lex_radix_number):

PrefixBaseExampleValue
0o / 0Ooctal0o755493
0x / 0Xhex0xFF255
0b / 0Bbinary0b1015
print(0o755)            -- 493
print(0xFF)             -- 255
print(0b101)            -- 5
print(0o755 == 493)     -- true  (it IS the f64 493)
493
255
5
true

_ digit separators are allowed anywhere in the body for readability — in decimals too:

print(1_000_000)        -- 1000000
print(0xFF_FF)          -- 65535
print(0b1010_1010)      -- 170
1000000
65535
170

Radix literals are checked at lex time. A digit out of range, an empty body, or a value past 2^53 is a clear lexer error rather than a silently-split or silently-rounded token:

print(0o78)             -- 8 is not an octal digit
Lexer error at line 1:7: '8' is not a valid octal digit in a 0o literal
print(0xG)              -- no hex digit
print(0x)               -- empty body
print(0x20000000000001) -- past 2^53
Lexer error at line 1:7: 'G' is not a valid hex digit in a 0x literal
Lexer error at line 1:7: 0x literal has no digits
Lexer error at line 1:7: 0x20000000000001 exceeds the exact-integer range (2^53); Mix numbers are f64

The token boundary is precise: only an alphanumeric follower is a bad digit. An operator, ., space, or ) is a legitimate end of the literal — 0xFF+1, 0o7 .. "x", and 0b101) all lex fine.

Use these for native chmod/write_new, where the mode is the f64 value: chmod($path, 0o755), write_new($path, $body, 0o600).

No bitwise operators

Mix has no & / | / ^ / << / >> operators and no bitwise builtins. ^ is not a power operator either — the power operator is ** (or the pow builtin). A bare & is a lexer error nudging you toward &&:

print(2 ^ 10)           -- '^' is not an operator
Lexer error at line 1:9: unexpected character '^'
print(2 ** 10)          -- ** is the power operator (real powf)
print(pow(2, 10))       -- same thing as a builtin
1024
1024

The bare-leading-zero trap

A decimal integer must not start with a redundant 0. Rust's f64 parser would silently read 0755 as 755, which is a footgun for a substrate whose daily work is octal file modes. Mix rejects it at lex time and tells you the fix (lexer.rs::lex_number):

print(0755)
Lexer error at line 1:7: ambiguous leading-zero number '0755' — use a 0o (octal) / 0x (hex) / 0b (binary) prefix, or drop the leading zero(s) for decimal

This is a Mix-expression hazard, not a shell one. When 0755 is a string (an argument in a shell-out), nothing is lexed as a number, so run("install -m 0755 a b") is fine. The error only fires when 0755 would be a Mix number literal.

Plain 0 and genuine fractions are unaffected — the rule looks only at the integer part:

print(0)        -- 0
print(0.5)      -- 0.5
print(0.05)     -- 0.05
0
0.5
0.05

Scientific-notation literals

The lexer accepts an [eE][+-]?digits exponent on a decimal literal (0.20.5+; earlier versions parse-errored on 1e3). It is sugar for the same f64 — _ separators work in the exponent too:

print(1e6)          -- 1000000
print(1.5e3)        -- 1500
print(2e-3)         -- 0.002
print(1E3)          -- 1000
print(1e+2)         -- 100
print(1e1_0)        -- exponent 10
1000000
1500
0.002
1000
100
10000000000

Two deliberate edges:

  • The e/E is only consumed when a digit (after an optional sign) actually follows, so the e() Euler-constant builtin is never eaten — 2 * e() is 5.43656…, and a bare 2e lexes as 2 followed by the token e.
  • The exponent is checked after the leading-zero rule, which sees only the mantissa: 0e5 is a valid 0, but 07e2 is still the ambiguous-leading-zero lexer error (see above).

Radix literals take no exponent — 0x1e3 is just hex digits (483).

Coercion and numeric strings

to_number(s) converts a value to an f64, or returns nil on failure (value.rs::to_number):

print(to_number("42"))     -- 42
print(to_number("3.14"))   -- 3.14
print(to_number("1e3"))    -- 1000 (exponent strings parse)
print(to_number("  7 "))   -- 7    (whitespace trimmed)
print(to_number(true))     -- 1    (bools coerce: true→1, false→0)
print(to_number("abc"))    -- nil  (not a number)
print(to_number("0xFF"))   -- nil  (radix prefixes are SOURCE syntax, not parseable at runtime)
42
3.14
1000
7
1
nil
nil

to_number parses a decimal string (sign, fraction, exponent). "abc" and "0xFF" both fail (the f64 parser doesn't accept a 0x prefix), so guard for nil if the input is untrusted.

String coercion is strict about non-finites (0.21+). Rust's f64 parser happens to accept the words "inf" / "infinity" / "nan" (any case), but those are not Mix numeric strings — and an overflowing spelling like "1e999" is unrepresentable:

print(to_number("inf"))    -- nil
print(to_number("nan"))    -- nil
print(to_number("1e999"))  -- nil (overflows f64)
print(is_number("inf"))    -- false (is_number agrees with to_number)
print(is_number("1e3"))    -- true
nil
nil
nil
false
true

Only string coercion is strict. A non-finite number value passes through untouched — to_number(sqrt(-1)) is NaN, and is_number(sqrt(-1)) is true (it is a number; math keeps propagating IEEE-754 non-finites).

Arithmetic operators auto-coerce a numeric string operand, so light shell-style input just works:

print("5" + 3)             -- 8   (string "5" coerces to 5)
8

to_number itself returns nil on failure because it is the explicit probe. Builtins and numeric language forms that require a number do not turn that failure into 0: they accept the same numeric strings and bools, but a supplied unparseable value raises TYPE_MISMATCH. Since 0.55.0 this is consistent across string positions/counts, range, numeric for, date/format helpers, sleep, exit(code), and arithmetic //% operands.

Equality treats a number and its numeric-string form as equal, and 5 == 5.0 is true because they are the same f64:

print("5" == 5)            -- true
print(5 == 5.0)            -- true
true
true

Ordering rules

The comparison operators < > <= >= follow a two-step rule (see operators for the full table):

  1. If both operands coerce to numbers (numbers, or numeric strings like "5"), compare numerically.
  2. Else, if both are strings, compare lexicographically by codepoint.
  3. Otherwise — a string-vs-number mix where the string isn't numeric — raise an error.
print("5" < "10")     -- true  (both numeric → 5 < 10, NOT string order)
print(5 < 10)         -- true
print("apple" < "banana")  -- true  (both non-numeric strings → lexicographic)
print("5" < "abc")    -- true  ("5" < "abc" lexicographically; not both numeric)
print(5 >= 5)         -- true
print(3 != 4)         -- true
true
true
true
true
true
true

The footgun bash brings is the opposite of Mix here: "5" < "10" is numeric (true), because both strings coerce. You only fall back to string ordering when at least one side won't coerce to a number. A genuine type clash errors rather than guessing:

print(5 < "abc")
Runtime error at line 1: cannot compare 'abc' as number

Modulo sign

% follows the f64 remainder (the result takes the sign of the dividend, like C/Rust %, not Python's floored modulo):

print(-7 % 3)     -- -1   (sign of -7)
print(7 % -3)     -- 1    (sign of 7)
print(5.5 % 2)    -- 1.5  (works on non-integers too)
-1
1
1.5

A zero divisor is a runtime error (modulo by zero), matching / — see the caveats section above.

Serialization: whole numbers travel as integers

Because the one type is f64, every serialization boundary decides whether a whole value leaves as an integer or a float. Mix consistently picks integer for whole numbers, so a peer field typed usize/i64 accepts it:

  • json_encode — a finite whole number in [-2^63, 2^63) encodes as a JSON integer; anything else (fractional, or ≥ 2^63) encodes as a JSON float (json.rs::mix_to_json). The upper bound is exclusive because i64::MAX as f64 rounds up to exactly 2^63 — an f64 of 2^63 encodes as the real 9.223372036854776e+18 instead of silently saturating.
  • sqlexec binds — the same rule, typed: a whole finite number binds as SQLite INTEGER, fractional/non-finite as REAL (nil→NULL, bool→0/1, string→TEXT, bytes→BLOB).
  • Bus send args — a whole number within ±2^53 serializes as a JSON integer (limit=2 reaches the peer as 2, not 2.0); fractional values stay floats.
print(json_encode(2))            -- 2 (integer, not 2.0)
print(json_encode(2.5))          -- 2.5
print(json_encode([1, 2.5, 3]))  -- [1,2.5,3]
2
2.5
[1,2.5,3]

Non-finite numbers refuse to serialize

print happily shows NaN/inf, but the serializers are loud about a non-finite number anywhere in the value — there is no JSON or strict-data representation for it:

  • json_encode raises a catchable error (0.21+; it used to encode 0 silently): json_encode: non-finite number NaN has no JSON representation (JSON has no NaN/Infinity).
  • data_encode and the strict-data writers refuse likewise (value.rs::write_mix_data): data_encode: Data serialize error: non-finite number NaN has no strict-data representation.

Keep generated config values finite, or try/catch the encode (errors).

See also

  • mathround / floor / ceil / trunc (each takes an optional decimal-places arg; negative rounds to tens/hundreds), abs, sqrt, pow, min/max/clamp, trig, pi()/e() — all propagate NaN/inf instead of erroring
  • operators — arithmetic incl. ** power, the full comparison/ordering table, and/or/not
  • strings.. concatenation, ${...} interpolation, codepoint counting
  • builtins indexto_number, chmod / write_new (mode is the f64 value), stat (ino/dev as strings), sqlexec typed binds
  • datajson_encode / data_encode serialization (both reject non-finite numbers)
  • bussend arg serialization (whole numbers as JSON integers)
  • The repo: https://github.com/markc/cosmix
  • mix help for the topic list; mix what to_number (or round, pow, …) for a one-line builtin reference