Types
WebAssembly’s value types, spelled like TypeScript.
Basic types
| Type | Wasm | Description |
|---|---|---|
i32 / u32 | i32 | 32-bit integer, signed / unsigned interpretation |
i64 / u64 | i64 | 64-bit integer |
i8 u8 i16 u16 | i32 | small integers for storage; compute in i32 (below) |
isize / usize | i32 | pointer-sized — 32-bit (wasm32) |
f32 / f64 | f32 / f64 | IEEE floats |
bool | i32 | true / false |
v128 | v128 | SIMD vector; needs --enable-simd |
- No implicit narrowing. Convert explicitly with
as:x as u8,f as i32. Float-to-int truncates and traps out of range (asc saturates). - Integer literals type contextually: a literal takes the type its
context asks for when it fits, otherwise
i32,u32ori64by magnitude; a literal that cannot fit a small type is an error rather than a wrap. - Orderings (
<,>,<=,>=) compare values across signedness, as TypeScript numbers do:x > -1holds for everyu32x. A signed and an unsigned operand widen to a signed type that holds both,i64for 32 bit ones, and au64against a signed value tests the sign first. A constant expression, unlike a bare literal, takes a 32 or 64 bit operand's type as in asc, sox > 0 - 1is false. Equality compares bits, as in asc. - No
any, noundefined, no union types; a nullable reference is writtenT?.
Small integers
WebAssembly has no 8 or 16 bit values. Locals, parameters, returns and
the operand stack hold only 32 and 64 bit integers and floats, so an
i8, u8, i16 or u16 is a
wasm i32 everywhere except memory. Memory is the one place with
real 8 and 16 bit values: loads and stores of those widths read and write
exactly one or two bytes.
The results still behave like true small integers, as in asc. Arithmetic
runs at 32 bits and may leave extra high bits, and every use that could see
them narrows first: comparisons, division, remainder, right shifts,
**, conversions to other types, conditions, returns,
switch, and the integer builtins abs,
clz, ctz, popcnt, min and
max. rotl and rotr rotate within the
small width, and constant folding follows the same rule. The
narrowing is one extra instruction, and it costs a tight loop that compares
or divides small integers 3 to 15 percent under the interpreter.
Addition, subtraction, multiplication, &, |,
^ and << never need it.
Store in small types, compute in i32. Declare fields,
arrays and struct members as u8 or i16 to save
bytes, where the store narrows for free. Declare locals, parameters, loop
counters and return values as i32 or u32, which
never narrow. Read a small field into an i32, work on it,
and cast once when you store it back.
declare function print(s: string): void;
class Pixel { r: u8 = 0; g: u8 = 0; }
export function demo(): void {
let x: u8 = 255;
x++;
print(x.toString()); // 0, as in C and asc
let p = new Pixel();
p.r = 200;
p.g = 200;
print(((p.r + p.g) >> 1).toString()); // 72, a u8 sum wraps before >>
// Compute in i32: no overflow and nothing to narrow.
let r: i32 = p.r;
let g: i32 = p.g;
p.r = <u8>((r + g) >> 1);
print(p.r.toString()); // 200
}
Nullability
Nullable types are reference-only: string?,
C?, (() => i32)?, with T?[] an
array of nullable elements and T[]? a nullable array. There are
no other unions, so T | null is an error. A nullable basic type
(i32?) is a compile error. Nullability folds correctly per
generic instance, so nullable array elements behave. Value classes exclude
nullability entirely: null refuses a value-class target at compile
time.
After x as T a ? followed by an operand starts a
ternary, as in Dart. At the end of a line it makes T nullable, so
a statement can end there without a semicolon, unless the next line is a
ternary branch followed by its :. In a class body a member on the
next line ends the field instead, so a field's ternary broken after
as T ? needs that ? on the next line or
parentheses.
The checker enforces the flag with a definite non-null analysis. A member
access, call, index, for..of or cascade on a nullable value warns
(W3004) unless the value is proven
non-null on that path, and so does a nullable value, or null itself,
stored into a non-nullable local, parameter, field, element or return. A proof
comes from if (x != null) and if (x) for the then
branch, if (x == null) for the else branch or for the rest of the
block when the then branch returns, breaks, continues or throws, the right side
of x != null && … and x == null || …, an
assignment of a proven value, x ??= y or x ||= y
with y proven, assert(x != null), and
x!, which proves x from then on. A ternary,
a || b or a ?? b whose arms are both proven counts
as proven too.
Four operators work on nullable values, as in Dart:
x!assertsxis not null and traps when it is. The check is skipped when the analysis already provedx, and insideunchecked().a ?? bisaunless it is null, evaluatingbonly then. The result is nullable only whenbis. Beside||or&&it needs parentheses, as in TypeScript.a ??= bstoresbintoawhenais null.a?.b,a?.m(),a?.[i]andf?.()stop at a null link and yield null for the whole chain, skipping everything after it, arguments included. A chain ending in a basic type such asa?.lengthcannot be null, so it needs a fallback,a?.length ?? 0, unless its value is dropped.
Field chains narrow like locals: after if (this.context != null)
the chain this.context stays proven until a store through any
prefix of it (this.context = …) or a method call on a proper
prefix (this.reset()), since a method can store into its
receiver's fields. A call on the chain itself (this.context.grow())
keeps the chain and only drops what sits below it. Inside a loop, every key the
loop stores or calls through is unproven for the whole loop; a closure starts
with no proofs, since captures are copies.
declare function print(s: string): void;
function firstName(names: Array<string?>): string {
let n = names[0];
if (n != null) {
return n; // narrowed to string
}
return "anonymous";
}
class User {
name: string? = null;
manager: User? = null;
}
export function demo(): void {
let empty: Array<string?> = [null, "rasc"];
let named: Array<string?> = ["rasc", null];
print(firstName(empty)); // anonymous
print(firstName(named)); // rasc
let user = new User();
print((user.manager?.name?.length ?? 0).toString()); // 0
user.manager ??= new User();
user.manager.name = "Ada";
print(user.manager?.name ?? "nobody"); // Ada
}
Array<T?> literals need an explicitly annotated
local before they can be assigned somewhere that expects the nullable element
type.
Narrowing never changes a type, only whether the checker warns, so
x != null ? x : y still has the nullable type. A proof about a
local or parameter also lets ! and ?. skip their
runtime null check; one about a global or a field does not, since a call or
another reference to the same object can null it without the checker seeing.
rasc --strict-null and rasc check --strict-null
make W3004 an error.
Reference classes
Ordinary classes are managed heap objects: a 20-byte header (mm/gc info,
runtime class id, size) followed by naturally aligned fields. The class id at
ptr - 8 drives instanceof, virtual dispatch, and GC
tracing. @unmanaged classes drop all of that — raw structs for
low-level work.
Value classes
struct types are plain-old-data with copy semantics — they live
in wasm locals, flatten into their containers, and never allocate. They get
their own chapter.
Function types
function apply(g: (x: i32) => i32, v: i32): i32 {
return g(v);
}
export function demo(): i32 {
let f: (x: i32) => i32 = (x: i32): i32 => x + 1;
return apply(f, 41); // 42
}
Function references are table indices wrapped in closure objects, so plain
functions and capturing closures mix freely behind the same type. Function-typed
values count as managed everywhere (roots, visitors, write barriers). One
restriction: a function-typed field cannot be called directly — read it
into a local first (let f = obj.fn; f(x)).
Enums and namespaces
enum Color { Red, Green = 5, Blue } // Blue == 6; values must be constant
namespace Geo {
export function area(w: f64, h: f64): f64 { return w * h; }
}
export function demo(): f64 {
return Geo.area(6, 7) + Color.Blue; // 48
}
Nested namespace paths resolve through scopes and enums
(String.UTF8.ErrorMode).
SIMD
With --enable-simd, v128 works as locals, params,
returns, and class fields, with f32x4 and i32x4 builtin
families (splat, lane access, arithmetic, compares, abs/neg/sqrt, min/max,
bitmask) plus the bitwise v128 ops and loads/stores. There is no
v128.const (so no v128 globals) and no shuffles. See
SIMD for the one-source-two-artifacts
pattern with ASC_FEATURE_SIMD.
Type queries and layout builtins
sizeof<T>(), alignof<T>(),
offsetof<T>("field") const-fold. For value classes they report
payload size and alignment; for reference classes, pointer size.
idof<T>() gives the runtime class id;
isDefined, isBoolean, isArray,
isFunction, and isNullable<T>() fold per
monomorphized instance — pair them with constant if folding for
compile-time specialization.