Semantics
Perry-WIT accepts a statically typed subset of TypeScript. Names, object layouts and host capabilities are resolved at compile time, and a construct outside the subset is a compile error with a source location.
Objects
- Interfaces, type aliases and object literals compile to records with fixed field offsets. Types with the same fields share a layout; classes and recursive types get their own.
- Records keep their declared fields, and prototype mutation is rejected.
Record<string, V>dictionaries accept new keys, anddeleteremoves dictionary keys and optional properties. - An empty object, including
const object: any = {}, becomes a dictionary when every write stores the same value type. Writes through local aliases, branches and loops count. An object that escapes, or whose writes store different types, needs a type annotation. - Unions of literals, records and WIT variants narrow through
typeof, equality and discriminant properties.
Arrays
- Arrays (
T[]) are dense. Elements share one type or a finite union of numbers, strings, booleans and one plain record shape. - Reading an index outside the array, or a fractional index, yields
undefinedwhen the element type admits it. - Writing at
lengthappends. Writing at any other index outside0..lengththrowsRangeError. - Fresh empty arrays (
[], includingany[]) infer their element type frompushcalls and numeric-indexed writes across branches, loops, non-reassigned local aliases and helper returns, widening string literals tostringand preserving nested record and array layouts. Inferred arrays support.length, index reads,Object.keys,Array.isArrayandJSON.stringifywithout a replacer; other uses, unresolved writes or no writes require an explicit element type.
Strings
- Strings are UTF-8. Length and indexing count Unicode scalar values and agree with Node.js for text in the Basic Multilingual Plane. Only
charCodeAtindexes UTF-16 code units, so past a character outside that plane its positions differ fromlengthandindexOf:'😀x'.lengthis 2, yet'😀x'.charCodeAt(2)reads thex. String.fromCharCodeand lone surrogate escapes (\uD800to\uDFFF) are compile errors. UseString.fromCodePoint.- Text arriving through the Canonical ABI,
JSON.parseorTextDecoderis validated as UTF-8.
Regular Expressions
- Regular expressions require compile-time constant patterns and flags (regex literals or
RegExpcalls with string literal arguments) and compile to search bytecode. test,exec,match,search,split,replaceandreplaceAllsupport named capture groups (.groupsonexecand non-globalmatch),$<name>replacement templates and synchronous replacement callbacks.
JSON
JSON.parsedecodes into the expected static target type (retaining extra object properties for serialization) or into a dynamic JSON value that is validated when assigned to a typed binding. Reviver callbacks are compile errors.JSON.stringifysupports synchronous replacer callbacks and property-allowlist arrays. The third indentation argument is a compile error.
Dates
Dateoperates in the UTC time zone.Date.parseandnew Date(string)accept ISO 8601 year, year-month, date and date-time strings (offset-free strings use UTC). Malformed ISO strings returnNaN, whereas non-ISO legacy date formats throwRangeError.Temporal.Instant,Temporal.PlainDateandTemporal.PlainDateTimeuse the ISO 8601 calendar (calendarIdis"iso8601", and non-ISO calendar annotations are rejected).PlainDate.fromandPlainDateTime.fromaccept ISO strings, existing instances or field objects (year,month,dayand optionalhourthroughnanosecond) with{ overflow: "constrain" | "reject" }.addandsubtractaccept{ years?, months?, weeks?, days? }with optionaloverflow, anduntilandsinceacceptlargestUnit("day","week","month","year","auto").
Exceptions
Error,TypeError,RangeError,ReferenceErrorandSyntaxErrorkeepname,messageandcausethroughtry,catchandfinally.- A failed implicit check produces a fresh error object when caught. Successful checks bypass the exception runtime; explicitly thrown values retain their identity.
- An export declared with a WIT
result<T, E>turns an uncaught exception intoerr. Without aresult, an uncaught exception traps, and a trap does not run pendingfinallyblocks.
Async
- An
asyncfunction runs eagerly until its first suspension. Awaiting an already settled promise continues inline. - There is no microtask queue in the guest. Waiting tasks resume from WASI 0.3 callbacks, so the order of continuations for different host events follows the host, not Node.js. Observers of the same promise resume in registration order.
Promise.raceleaves the losing tasks running. They must settle before the export returns, or the call traps.ReadableStreamsupports default readers (getReader()),for awaitloops, BYOB readers (getReader({ mode: "byob" })withread(view, { min })into a caller-providedUint8Array) and explicit cancellation.for awaitover a byte stream releases the reader lock and cancels the stream when the loop exits early. Releasing a reader lock rejects pending reads but keeps buffered chunks for the next reader.
Numbers
WIT provides fixed-width integers (u8..u64, s8..s64) alongside f32 and f64, while TypeScript provides number (IEEE 754 f64) and bigint.
| WIT type | .d.ts type | Guest representation |
|---|---|---|
u8, u16, u32, s8, s16, s32 | number | Refined number (i32, i64 or f64) |
u64, s64 | bigint | i64 at boundaries, arbitrary-width bigint in guest |
f32, f64 | number | f64 |
Keeping u8..s32 as number in generated .d.ts files lets guest code use standard TypeScript arithmetic while giving the compiler range information to emit integer instructions.
Refinement Analysis
Each numeric SSA value carries an optional integer type (width, signedness) and a value interval [min, max].
ProgramFacts propagates ranges forward through arithmetic, branches and calls (including recursion), and propagates required register widths and observation requirements backward through the same worklist to a fixed point [1].
- Register selection: Refined integers whose interval fits in 32 bits lower to
i32. Wider refined integers lower toi64. Unrefined values stay inf64. - Bitwise results:
&,|,^,~,<<,>>andMath.imulproduces32values and>>>producesu32, so they lower toi32instructions even when their operands are unrefinedf64values, which convert withToInt32. When every use needs only the low bits, refined arithmetic feeding them, such as(a * b) | 0onu32values, computes ini32. This deliberately preserves integer wrapping before conversion: ata = b = 4294967295, the result is1, whereas JavaScript rounds the product before converting and returns0. A use that observes the full value, such as arithmetic or a comparison, requires a wider register when the result does not fit; a bitwise consumer then truncates at its own use. - Register-width intermediates: Required widths stay at register size (32 or 64 bits) during computation. Narrowing to 8 or 16 bits happens only at the WIT boundary.
- Literals: An integer literal adopts a required width only when its value fits. Larger literals stay in
f64until converted at the boundary. - Loops: At a join point, an interval may grow once before widening to the bounds of its register type, or to unbounded endpoints for unrefined numbers. A specialized loop counter wraps at 32 or 64 bits on overflow.
- Guarded ranges: Dominating comparisons of integral operands refine index ranges through the numeric solver’s shared arithmetic and integer bounds. Derived arithmetic and cast ranges must fit the result type. Addition and subtraction preserve one-sided bounds; arithmetic that may produce
NaNstays unknown. - Bounds checks: A proof of
0 <= i < lengthpermits unchecked array reads and stores, andUint8Arrayreads. Array proofs account for mutations through aliases. Byte-view proofs use length comparisons or constructor, copy and subarray extents, and require the view to stay attached on every path to the access. Calls, awaits, foreign operations and BYOB reads invalidate these byte-view proofs.
Type Join
Combining two operands takes the least upper bound of their refinements:
| Operands | Join | Reason |
|---|---|---|
u8, u16 | u16 | Wider type of the same signedness. |
u8, s8 | s16 | Holds both 0..=255 and -128..=127. |
u16, s16 | s32 | Next signed width up. |
u32, s32 | s64 | Next signed width up. |
u8, s32 | s32 | Signed operand is already wider. |
integer, unrefined number | f64 | Unrefined number may be fractional. |
Generated Code
package example:numerics@0.1.0;
world numerics {
export combine-delta: func(base: u8, delta: s8) -> s16;
export scale-bytes: func(count: u32, stride: u32) -> u32;
}
export function combineDelta(base: number, delta: number): number {
return base + delta;
}
export function scaleBytes(count: number, stride: number): number {
return Math.trunc((count * stride) / 1024);
}
Compiling this pair with --core-only emits:
(module
(type (;0;) (func (param i32 i32) (result i32)))
(export "scale-bytes" (func $__perry_integer_3_scaleBytes))
(export "combine-delta" (func $wit_combine_delta))
(func $__perry_integer_1_combineDelta (;0;) (type 0) (param $v35 i32) (param $v36 i32) (result i32)
local.get $v35
local.get $v36
i32.add
i32.extend16_s
)
(func $__perry_integer_3_scaleBytes (;1;) (type 0) (param $v39 i32) (param $v40 i32) (result i32)
(local $v47 f64)
local.get $v39
i64.extend_i32_u
local.get $v40
i64.extend_i32_u
i64.mul
f64.convert_i64_u
f64.const 0x1p+10 (;=1024;)
f64.div
f64.trunc
local.tee $v47
local.get $v47
f64.const 0x1p-32 (;=0.00000000023283064365386963;)
f64.mul
f64.trunc
f64.const 0x1p+32 (;=4294967296;)
f64.mul
f64.sub
i64.trunc_sat_f64_s
i32.wrap_i64
)
(func $wit_combine_delta (;2;) (type 0) (param $v56 i32) (param $v57 i32) (result i32)
local.get $v56
i32.const 255
i32.and
local.get $v57
i32.extend8_s
call $__perry_integer_1_combineDelta
)
)
combine-delta: The export adapter$wit_combine_deltamasksbase(i32.and) and sign-extendsdelta(i32.extend8_s). The sum lies in-128..=382, so the body adds directly ini32.scale-bytes: The product of twou32values is below 264, soi64.mulcomputes it exactly. Converting that exact product tof64rounds once, matching JavaScript floating-point multiplication./is floating-point division, so the division stays inf64.
Boundary Conversion
- Incoming
u8..s16: The export adapter masksu8andu16arguments and sign-extendss8ands16arguments. - Outgoing
u8..s32: Values convert with JavaScript typed-array semantics (ToUint8throughToInt32), truncating toward zero, mappingNaNand±Infinityto0, and wrapping modulo 2N. Integer andf64code paths produce identical results. u64,s64(bigint): Out-of-rangebigintreturns trap at the boundary. Inside guest code,+,-, unary-, comparisons andtoString()support arbitrary width.*,/,%andBigInt(number)require 64-bit operands.
package example:boundaries@0.1.0;
world boundaries {
export scale8: func(a: u8, b: u8) -> u8;
export to-u8: func(x: f64) -> u8;
export to-s32: func(x: f64) -> s32;
export dec-u64: func(x: u64) -> u64;
}
export function scale8(a: number, b: number): number { return a * b; }
export function toU8(x: number): number { return x; }
export function toS32(x: number): number { return x; }
export function decU64(x: bigint): bigint { return x - 1n; }
| Call | Result | JavaScript |
|---|---|---|
scale8(200, 2) | 144 | new Uint8Array([400])[0] |
to-u8(-1) | 255 | new Uint8Array([-1])[0] |
to-s32(3000000000) | -1294967296 | 3000000000 | 0 |
dec-u64(1) | 0 | 1n - 1n |
dec-u64(0) | trap | -1n is outside u64 |
References
- M. Stephenson, J. Babb, S. Amarasinghe. Bitwidth Analysis with Application to Silicon Compilation. ACM PLDI, 2000.