language

Types

WebAssembly’s value types, spelled like TypeScript.

Basic types

TypeWasmDescription
i32 / u32i3232-bit integer, signed / unsigned interpretation
i64 / u64i6464-bit integer
i8 u8 i16 u16i32small integers for storage; compute in i32 (below)
isize / usizei32pointer-sized — 32-bit (wasm32)
f32 / f64f32 / f64IEEE floats
booli32true / false
v128v128SIMD vector; needs --enable-simd

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.

guidance

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.

small_ints.as
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:

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.

nullable.as
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
}
gotcha

Array<T?> literals need an explicitly annotated local before they can be assigned somewhere that expects the nullable element type.

gotcha

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

functions.as
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

enums.as
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.