Numbers
Zena provides primitive numeric types covering signed integers, unsigned integers,
narrow storage integers, and IEEE 754 floating-point numbers, plus
BigInt in zena:bigint for arbitrary-precision signed integers.
The primitive numeric types are unboxed WebAssembly values with no heap allocation.
Integers ​
Zena supports two standard integer widths, each available in signed and unsigned variants:
| Type | Bit width | Signedness | Min value | Max value | WebAssembly type |
|---|---|---|---|---|---|
i32 |
32-bit | Signed | -2_147_483_648 (-2³¹) |
2_147_483_647 (2³¹ - 1) |
i32 |
u32 |
32-bit | Unsigned | 0 |
4_294_967_295 (2³² - 1) |
i32 |
i64 |
64-bit | Signed | -9_223_372_036_854_775_808 (-2⁶³) |
9_223_372_036_854_775_807 (2⁶³ - 1) |
i64 |
u64 |
64-bit | Unsigned | 0 |
18_446_744_073_709_551_615 (2⁶⁴ - 1) |
i64 |
i32 is the default integer type in Zena. Integer literals without a decimal point
or contextual type annotation evaluate to i32:
let count = 42; // Inferred as i32
let bigCount: i64 = 5_000_000_000; // Typed as i64 via contextual annotation
Both i32 and u32 map directly to the WebAssembly i32 value type, and i64 and
u64 map directly to WebAssembly i64. The difference between signed and unsigned
types is semantic: operations that depend on sign (such as division, modulo, shifts,
and comparisons) emit sign-appropriate WebAssembly instructions.
Narrow integers ​
Narrow integers represent exact sub-word storage widths:
| Type | Bit width | Signedness | Min value | Max value | Storage size |
|---|---|---|---|---|---|
i8 |
8-bit | Signed | -128 |
127 |
1 byte |
u8 |
8-bit | Unsigned | 0 |
255 |
1 byte |
i16 |
16-bit | Signed | -32_768 |
32_767 |
2 bytes |
u16 |
16-bit | Unsigned | 0 |
65_535 |
2 bytes |
Motivation and WebAssembly architecture ​
In WebAssembly GC, the type system distinguishes between value types (the types allowed for local variables, global variables, function parameters, and return values) and storage types (the types allowed inside heap-allocated arrays and struct fields).
WebAssembly GC only provides 32-bit and 64-bit value types (i32, i64, f32, f64).
There is no WebAssembly instruction or operand-stack type for an 8-bit or 16-bit
integer. Consequently, a local variable, parameter, or return value cannot physically
be i8 or u8 in WebAssembly—it must be an i32.
However, applications frequently need to work with byte buffers, binary network payloads,
string encodings, and WebAssembly Component Model (WIT) interfaces. Storing bytes in
32-bit words would quadruple memory usage. WebAssembly GC solves this by supporting
packed storage types in arrays ((array i8) and (array i16)).
To support packed array storage and typed Component Model boundaries without sacrificing
type safety, Zena introduces first-class narrow integer types (i8, u8, i16, u16).
Dual representation model ​
Narrow integers follow a dual representation model: they are stored packed and computed wide:
| Location | Representation | WebAssembly implementation |
|---|---|---|
Array elements (ByteArray, FixedArray<u8>) |
Packed (8-bit or 16-bit) | Native (array i8) / (array i16) storage |
| Locals, globals, parameters, return values | Unpacked (32-bit) | WebAssembly i32 register |
| Class, record, and tuple fields | Unpacked (32-bit) | WebAssembly i32 field storage |
- Reading from packed storage: Reading an element from a packed array (e.g.
b[i]) emitsarray.get_ufor unsigned types (u8,u16) to zero-extend the byte into ani32, orarray.get_sfor signed types (i8,i16) to sign-extend it. The unpacked value always carries the exact, mathematically correct integer in a 32-bit register. - Writing to packed storage: Writing into a packed array (
b[i] = val) emitsarray.set, which automatically truncates the 32-bit register value to the low 8 or 16 bits. - Canonical normalized form: Because unpacked values are held in canonical normalized form (zero-extended for unsigned, sign-extended for signed), downstream operations, comparisons, and function calls execute directly as standard 32-bit WebAssembly instructions without runtime bitmasking overhead.
Narrow promotion rule ​
Narrow integers are storage types, not arithmetic types. In any arithmetic
operation (+, -, *, /, %), narrow operands automatically promote to their
32-bit counterpart before computation:
i8andi16promote toi32u8andu16promote tou32
Narrow types never survive an arithmetic operation:
let a: u8 = 200;
let b: u8 = 100;
let sum = a + b; // Inferred as u32, evaluates to 300
Because a + b produces a u32, storing the result back into a narrow storage location
requires an explicit as cast:
var byte: u8 = 10;
byte = (byte + 1) as u8; // Explicit truncation and normalization
This prevents unexpected truncation bugs (e.g. 200 + 100 silently wrapping to 44)
while ensuring that byte arithmetic is efficient and easy to reason about.
Arbitrary-precision integers ​
BigInt in zena:bigint represents immutable signed integers of
arbitrary size. Each value stores a sign (-1, 0, or 1) and a normalized
little-endian FixedArray<u64> of magnitude limbs, with BigInt.ZERO and
BigInt.ONE as shared constants.
Literals, construction, and parsing ​
Integer literals with a trailing n (or _n) suffix evaluate to BigInt directly,
in decimal, hexadecimal (0x), binary (0b), or octal (0o) notation of any bit
width, without requiring an explicit import. Unsuffixed integer literals also adapt to
BigInt when BigInt is expected by a type annotation, union, operator, call
parameter, or match arm:
let a = 42n;
let b = 0x1_0000_0000_0000_0000_0001n;
let c = -0b1010_0001_n;
let next = b + 1; // 1 adapts contextually to BigInt
To convert runtime integers or strings (including radixes outside 10, 16, 2, and
8), import BigInt from zena:bigint. BigInt.from accepts i32, i64, u64, or
String (with an optional radix in 2..36, or 0 to detect 0x, 0b, and 0o
prefixes with base-10 fallback), throwing Error on malformed strings. String inputs
accept an optional + or - sign and _ digit separators. BigInt.parse(text, radix)
accepts the same string forms and returns BigInt | null without throwing.
import {BigInt} from 'zena:bigint';
let fromInt = BigInt.from(42);
let base36 = BigInt.from('-zz', 36); // -1295n
let maybe = BigInt.parse('not-a-number'); // null
Arithmetic, bitwise, and number-theoretic operations ​
BigInt overloads the binary arithmetic, equality, and bitwise operators:
- Arithmetic:
+,-,*,%(truncating remainder with the dividend's sign), and**(non-negativei32exponent;a.pow(exp)accepts non-negativei64). - Equality:
==and!=compare values structurally. - Bitwise:
&,|,^,<<, and>>(non-negativei32shift counts; negative counts throwError). Bitwise operations on negative values follow infinite two's-complement semantics, matching Python and JavaScriptBigInt, and>>sign-extends (floor(a / 2^bits)).
Operations whose operator syntax is reserved or not yet overloadable on classes are exposed as methods:
- Truncating division:
a.div(b)returns the quotient truncated toward zero, anda.divRem(b)returnsinline (quotient, remainder)(/in Zena always performs floating-point division). - Unary operations:
a.neg(),a.abs(), anda.bitNot()(-(a + 1)). - Ordering:
a.compareTo(b)returns-1,0, or1;a.min(b)anda.max(b)select the smaller or larger value. - Number theory:
a.gcd(b)returns the non-negative greatest common divisor, anda.modPow(exp, mod)computes(a ** exp) % modwith a non-negative residue in0 .. mod - 1.
let base = 123_456_789_012_345_678_901_234_567_890n;
let mod = 1_000_000_007n;
let (q, r) = base.divRem(mod);
let residue = base.modPow(65_537n, mod);
Conversions, formatting, and collections ​
tryToI64() and tryToU64() return inline (true, value) when the value fits in 64
bits and (false, _) otherwise; toI64() and toU64() throw Error on overflow.
toString(radix) formats in any radix from 2 to 36 (default 10). BigInt
implements Stringable and Hashable, so values interpolate directly in template
literals and work as keys in Map and Set.
Floats ​
Zena supports IEEE 754 floating-point numbers in single and double precision:
| Type | Precision | Bit width | WebAssembly type |
|---|---|---|---|
f32 |
Single precision | 32-bit | f32 |
f64 |
Double precision | 64-bit | f64 |
f64 is the default floating-point type in Zena for literals without context:
let pi = 3.141592653589793; // Inferred as f64
let ratio: f32 = 3.14159; // Typed as f32 via annotation
let scaled = 1.5 as f32; // Or via a cast
Rationale for the default widths ​
Integer literals default to the narrow width (i32) and float literals to the
wide one (f64). The two families differ in what the narrow choice risks.
An f32 holds about 7 decimal digits and exact integers only up to
16,777,216, which ordinary code passes without trying: a total in cents stops
being exact at $167,772.16, and accumulating 0.01 a million times gives
9865.22 instead of 10000. An f64 holds about 16 digits and every integer up
to 2⁵³. The extra precision costs 4 bytes per stored value and half as many
lanes per SIMD vector, while scalar addition and multiplication run at the same
rate as f32 on the CPUs a WebAssembly engine targets.
An i32 reaches 2,147,483,647, which already covers the indices, lengths,
counts and loop variables that unannotated integers hold — Array.length is
an i32. Code handling values beyond that range, such as file sizes or
nanosecond timestamps, is specific enough to name i64 at the declaration.
Defaulting to i64 would double the storage of every integer field and array
element for a range most programs never reach, and 64-bit division is slower
than 32-bit.
Both defaults yield to context, so let x: f32 = 1.5; and let n: i64 = 1;
need no cast. Full rationale:
types.md.
Division behavior ​
The division operator / always performs floating-point division in Zena, even when
both operands are integers:
let x: i32 = 7;
let y: i32 = 2;
let q = x / y; // 3.5 (f64)
Dividing two integer operands evaluates to f64 to prevent integer truncation bugs
(such as 1 / 2 == 0). Dividing f32 operands produces f32:
let a: f32 = 7.0;
let b: f32 = 2.0;
let result = a / b; // 3.5 (f32)
For truncating integer division, import div from zena:math, which lowers directly
to native WebAssembly integer division instructions (i32.div_s, i64.div_s,
i32.div_u, i64.div_u):
import {div} from 'zena:math';
let quotient = div(7, 2); // 3 (i32)
Mathematical functions ​
zena:math provides floating-point and integer math utilities:
- WebAssembly float intrinsics:
sqrt,abs,neg,floor,ceil,trunc,nearest,min,max, andcopysign, overloaded overf64andf32. - Elementary functions:
exp,exp2,expm1,log,log2,log10,log1p,sin,cos,tan,asin,acos,atan,atan2,sinh,cosh,tanh,asinh,acosh,atanh,pow,cbrt,hypot, andfma, overloaded overf64andf32and accurate to within 1 ulp (fmais exact). ConstantsPI,TAU,E,LN2,LN10, andSQRT2are exported asf64, andisNaNtests forNaN. - Integer division, bit counting, bounds, and 128-bit arithmetic:
div(i32,i64,u32,u64), bit-counting intrinsics (clz,ctz,popcntoveri32,i64,u64), integer limit constants (I32_MIN..U64_MAX), 128-bit wide operations (mulWide,mulWideSigned,add128,sub128) returninginline (low, high)tuples, saturating float-to-integer truncations (i32_trunc_s,i32_trunc_u,i64_trunc_s,i64_trunc_u), and bit reinterpretations (i32_reinterpret_f32,i64_reinterpret_f64,f32_reinterpret_i32,f64_reinterpret_i64).
import {exp, log, sin, atan2, hypot, pow, clz, mulWide, PI} from 'zena:math';
let growth = exp(0.05); // 1.0512710963760241
let halfLife = log(2.0) / 0.1; // 6.931471805599452
let height = 10.0 * sin(PI / 4.0); // 7.071067811865475
let angle = atan2(3.0, 4.0); // 0.6435011087932844
let distance = hypot(3.0, 4.0); // 5.0
let root = pow(27.0, 1.0 / 3.0); // 3.0
let leading = clz(16); // 27
let (lo, hi) = mulWide(1000000000000 as u64, 1000000000000 as u64);
** on floats lowers to a squaring loop that truncates a non-integer
exponent, so write pow(x, y) for a non-integer y until it lowers to
pow (#878).
Number literals ​
Zena supports decimal, hexadecimal (0x/0X), binary (0b/0B), and octal
(0o/0O) integer literals, BigInt integer literals suffixed with n (or _n),
and decimal floating-point literals. Single _ digit separators may appear between
adjacent digits in any numeric literal:
let decimal = 1_000_000;
let hex = 0xff_00; // 65280
let binary = 0b1010_0001; // 161
let octal = 0o755; // 493
let big = 100_000_000_000_000_000_000_000n; // BigInt
let floatVal = 3.1415_9265;
Hexadecimal, binary, and octal literals targeting signed i32 or i64 may use every
bit as a two's-complement bit pattern (0xffff_ffff is -1 as i32, and
0b1111_1111_1111_1111_1111_1111_1111_1111 is -1 as i32).
Contextual typing ​
Number literals in Zena infer their type contextually from surrounding code:
- Variable annotations: When assigned to an explicitly typed variable, the literal
adopts that type:zena
let a: i64 = 100; // Literal 100 is typed as i64 let b: u8 = 255; // Literal 255 is typed as u8 let c: f64 = 1.0; // Literal 1.0 is typed as f64 let d: BigInt = 100_000_000_000_000_000_000_000; // Adapts directly to BigInt - Binary expressions: In binary operations, an untyped literal adopts the type of
the other operand:zena
let count: i64 = 100; let next = count + 1; // 1 is inferred as i64; next is i64 - Function calls: An untyped literal argument adapts to the parameter type of the
function or method being invoked:zena
function delay(ms: u64): void { ... } delay(5000); // 5000 is inferred as u64
Compile-time range checking ​
When a literal takes its type contextually, the compiler verifies at compile time that the literal's value fits within the target type's representable range:
let validByte: u8 = 255; // OK
let overflowByte: u8 = 256;
Error:
Negative literals are measured as a whole with their unary negation:
let minI8: i8 = -128; // OK: fits within i8 range (-128..127)
let badI8: i8 = -129; // Error: out of range
Error:
Range checking applies to all narrow types (i8, u8, i16, u16) and unsigned
types (u32, u64).
Unsigned semantics ​
Unsigned integer types (u32, u64) treat bit patterns as non-negative magnitudes.
Operations that depend on signedness behave differently on unsigned types:
- Comparison: Relational operators (
<,<=,>,>=) perform unsigned comparisons. For example,0xffff_ffff as u32 > 0 as u32istrue(4,294,967,295 > 0), whereas fori32,-1 > 0isfalse. - Modulo:
%computes the unsigned remainder usingi32.rem_u/i64.rem_u. - Right shift: Shifting an unsigned integer with
>>performs a logical zero-filling shift, identical to>>>.
Strict separation of signed and unsigned types ​
Zena enforces a strict separation between signed and unsigned types. Mixing signed and unsigned operands in binary operations or comparisons without an explicit cast is a compile-time error:
let s: i32 = 10;
let u: u32 = 20;
let sum = s + u;
Error:
Zena deliberately rejects implicit mixed-signedness operations because neither
automatic reinterpretation nor implicit widening to i64 is universally safe. To
combine signed and unsigned values, convert one operand explicitly:
let total = (s as u32) + u; // Explicit cast
Overflow and special values ​
Integer overflow ​
Integer arithmetic in Zena follows standard two's complement modular arithmetic without runtime overflow checks. Operations that exceed the representable range wrap around:
let max: i32 = 2147483647;
let wrapped = max + 1; // -2147483648
For exact signed integer arithmetic beyond 64 bits, see Arbitrary-precision integers.
Integer division by zero ​
Dividing an integer by zero using div or % traps at runtime with a WebAssembly
integer divide-by-zero runtime error:
import {div} from 'zena:math';
let zero = 0;
let trapped = div(10, zero); // Runtime trap: integer divide by zero
Floating-point special values ​
Floating-point operations adhere to the IEEE 754 standard and never trap:
- Division by zero: Dividing a non-zero float by zero evaluates to
Infinityor-Infinity. Dividing zero by zero (0.0 / 0.0) evaluates toNaN. - Special values:
Infinity: Positive infinity-Infinity: Negative infinityNaN: Not-a-number-0.0: Negative zero (compares equal to+0.0with==)
In accordance with IEEE 754 rules, NaN is not equal to any value, including itself:
let n = 0.0 / 0.0;
n == n; // false
To test whether a floating-point value is NaN, import isNaN from zena:math:
import {isNaN} from 'zena:math';
if (isNaN(n)) {
// Value is NaN
}
Numeric conversions ​
Explicit casting with as ​
Conversions between numeric types must be explicit using the as operator:
let count: i32 = 100;
let bigCount = count as i64; // Sign-extended to i64
let floatCount = count as f64; // Converted to f64
let truncated = 3.99 as i32; // Truncated towards zero to 3
When casting a wider integer to a narrow integer type, the value is truncated to the lower bits and normalized:
- Casting to unsigned narrow types (
as u8,as u16) masks to the lowest 8 or 16 bits:zenalet b = 300 as u8; // 300 % 256 = 44 - Casting to signed narrow types (
as i8,as i16) truncates and sign-extends the result:zenalet s = 200 as i8; // -56
Implicit promotion hierarchy ​
In binary arithmetic operations (+, -, *, %), operands are symmetrically
promoted according to the following hierarchy:
f64dominance: If either operand isf64, the result isf64.f32with 64-bit integers: Mixingf32withi64oru64promotes tof64to preserve precision.f32with 32-bit integers: Mixingf32withi32oru32promotes tof32.- Narrow integer promotion: Narrow integers (
i8,u8,i16,u16) promote to their 32-bit counterparts (i32oru32) before arithmetic. - Signed widening: An
i32mixed withi64widens toi64. - Mixed signedness: Mixing a signed integer with an unsigned integer is always a compile-time error.
let a: i32 = 10;
let b: i64 = 20;
let c = a + b; // i64 (a widens to i64)
let f: f32 = 2.5;
let d = a + f; // f32 (a promotes to f32)