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:

zena
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]) emits array.get_u for unsigned types (u8, u16) to zero-extend the byte into an i32, or array.get_s for 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) emits array.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:

  • i8 and i16 promote to i32
  • u8 and u16 promote to u32

Narrow types never survive an arithmetic operation:

zena
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:

zena
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:

zena
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.

zena
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-negative i32 exponent; a.pow(exp) accepts non-negative i64).
  • Equality: == and != compare values structurally.
  • Bitwise: &, |, ^, <<, and >> (non-negative i32 shift counts; negative counts throw Error). Bitwise operations on negative values follow infinite two's-complement semantics, matching Python and JavaScript BigInt, 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, and a.divRem(b) returns inline (quotient, remainder) (/ in Zena always performs floating-point division).
  • Unary operations: a.neg(), a.abs(), and a.bitNot() (-(a + 1)).
  • Ordering: a.compareTo(b) returns -1, 0, or 1; a.min(b) and a.max(b) select the smaller or larger value.
  • Number theory: a.gcd(b) returns the non-negative greatest common divisor, and a.modPow(exp, mod) computes (a ** exp) % mod with a non-negative residue in 0 .. mod - 1.
zena
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:

zena
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:

zena
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:

zena
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):

zena
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, and copysign, overloaded over f64 and f32.
  • 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, and fma, overloaded over f64 and f32 and accurate to within 1 ulp (fma is exact). Constants PI, TAU, E, LN2, LN10, and SQRT2 are exported as f64, and isNaN tests for NaN.
  • Integer division, bit counting, bounds, and 128-bit arithmetic: div (i32, i64, u32, u64), bit-counting intrinsics (clz, ctz, popcnt over i32, i64, u64), integer limit constants (I32_MIN..U64_MAX), 128-bit wide operations (mulWide, mulWideSigned, add128, sub128) returning inline (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).
zena
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:

zena
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:

  1. 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
    
  2. 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
    
  3. 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:

zena
let validByte: u8 = 255; // OK
let overflowByte: u8 = 256;

Negative literals are measured as a whole with their unary negation:

zena
let minI8: i8 = -128; // OK: fits within i8 range (-128..127)
let badI8: i8 = -129; // Error: out of range

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 u32 is true (4,294,967,295 > 0), whereas for i32, -1 > 0 is false.
  • Modulo: % computes the unsigned remainder using i32.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:

zena
let s: i32 = 10;
let u: u32 = 20;

let sum = s + u;

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:

zena
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:

zena
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:

zena
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 Infinity or -Infinity. Dividing zero by zero (0.0 / 0.0) evaluates to NaN.
  • Special values:
    • Infinity: Positive infinity
    • -Infinity: Negative infinity
    • NaN: Not-a-number
    • -0.0: Negative zero (compares equal to +0.0 with ==)

In accordance with IEEE 754 rules, NaN is not equal to any value, including itself:

zena
let n = 0.0 / 0.0;
n == n; // false

To test whether a floating-point value is NaN, import isNaN from zena:math:

zena
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:

zena
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:
    zena
    let b = 300 as u8; // 300 % 256 = 44
    
  • Casting to signed narrow types (as i8, as i16) truncates and sign-extends the result:
    zena
    let s = 200 as i8; // -56
    

Implicit promotion hierarchy ​

In binary arithmetic operations (+, -, *, %), operands are symmetrically promoted according to the following hierarchy:

  1. f64 dominance: If either operand is f64, the result is f64.
  2. f32 with 64-bit integers: Mixing f32 with i64 or u64 promotes to f64 to preserve precision.
  3. f32 with 32-bit integers: Mixing f32 with i32 or u32 promotes to f32.
  4. Narrow integer promotion: Narrow integers (i8, u8, i16, u16) promote to their 32-bit counterparts (i32 or u32) before arithmetic.
  5. Signed widening: An i32 mixed with i64 widens to i64.
  6. Mixed signedness: Mixing a signed integer with an unsigned integer is always a compile-time error.
zena
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)