concepts

Classes & Generics

Classes, interfaces, and virtual dispatch work like upstream AssemblyScript’s; generics are specialized to concrete types at compile time.

Classes, inheritance, interfaces

classes.as
class Base {
    name(): i32 { return 1; }
    describe(): i32 { return this.name() * 2; }
}

class A extends Base {
    name(): i32 { return 3; }
}

interface Shape { area(): i32; }

class Square implements Shape {
    side: i32 = 3;
    area(): i32 { return this.side * this.side; }
}

export function demo(): i32 {
    // Virtual dispatch via rtId: 6 + 9.
    return (new A() as Base).describe() + (new Square() as Shape).area();
}

Fields with initializers, constructors (implicitly returning this), methods, statics, getters/setters, extends with super(), instanceof via rtti. Calls through a base reference dispatch virtually, matching upstream AssemblyScript, while super.m(), and reads and writes of super.x, reach the base’s own method, accessor or field. Abstract methods compile to unreachable stubs and are enforced as upstream AssemblyScript enforces them — no new of an abstract class, missing implementations are errors.

Instance operators dispatch the same way: an operator[], operator+ or other instance operator a subclass overrides runs for the object’s runtime class when reached through a base reference, and an override is checked against the operator it replaces like a method. Static operators and struct operators always resolve at compile time.

== and != with a null operand compare pointers, as TypeScript reads them, so no operator== runs against null, through an interface either. An instance operator== or operator!= with a nullable operand runs only when both sides hold an object. Otherwise two references are equal when both are null, and a null left never equals a number.

A class implementing an interface, itself or through an abstract base, must provide every member of that interface and of the interfaces it extends. A method takes the member’s parameter types, optionally followed by parameters with defaults, and returns the member’s type or a class that extends or implements it, or anything when the member returns void and the method is not async. A field holds the member’s type, or for a readonly member a class that extends or implements it, and integer fields keep their own width within 32 or 64 bits, so a u8 field can implement an i32 member. Accessors need a setter unless the member is readonly. Anything else is a compile error, including fewer parameters than the member takes, which TypeScript allows, and an async method for a member that returns no Promise. A class implements an interface and cannot extends one.

Operators and generic methods an interface declares dispatch on the runtime class as its methods do, so g[i], g + 1 or m.map<i32>(5) through an interface reaches the implementing class’s own. The class implements each with an instance operator or a generic method of the same signature, where a static operator or a method without type parameters does not count. An interface declares one operator per symbol, and its operator[] takes one operand. A null left operand has no runtime class, so an interface operator other than == and != traps on it.

Interfaces can be generic. Each instance, such as Getter<i32>, is checked with its own type arguments, can extend other instances, and works with instanceof:

getter.as
interface Getter<T> { get(): T; }

class Five implements Getter<i32> {
    get(): i32 { return 5; }
}

export function demo(): i32 {
    let g: Getter<i32> = new Five();
    return g instanceof Getter<i32> ? g.get() : 0;   // 5
}

Construction follows upstream AssemblyScript, not TypeScript. A class with its own constructor has its fields initialized, base classes first, before that constructor runs, so its initializers do not see what a base constructor sets; a class without one initializes its fields after the inherited constructor returns. A derived constructor calls super(...) once, as a statement at its top level, before it uses this or returns; code that leaves this alone may come first. As in TypeScript, a class without initialized fields or parameter properties may instead call it in each arm of an if, once on every path, where an arm that throws needs none. Parameter properties such as constructor(public x: i32) declare fields the constructor stores as it starts: inherited initializers never overwrite them, and field initializers cannot read them. Calling a method a subclass overrides while constructing warns (W3005). A class cannot extend an @final class, or one whose constructor can return a different object.

Implicit this

Inside a method, a bare name that nothing else in scope declares resolves to a member of the enclosing class: fields, accessors and methods, inherited ones included. A member never shadows a local, a parameter, a captured variable, a module scope name or a type, so this. is only needed to disambiguate, and it always works. Static members stay behind the class name, a static method has no this to reach members through, and field initializers see no members. Builtin names such as abs or load are reserved, so a member named like one needs this.. When a global shares a member's name the global wins, and the compiler warns (W1014).

implicit_this.as
class Counter {
    value: i32 = 0;
    step: i32 = 1;
    advance(): i32 {
        value += step;          // this.value += this.step
        return describe(value); // this.describe(this.value)
    }
    describe(value: i32): i32 {
        return value * 2;       // the parameter, not the field
    }
}

Cascades

cascades.as
class Paint {
    color: u32 = 0;
    width: f32 = 1;
    child: Paint? = null;
    thicker(by: f32): Paint {
        this.width += by;
        return this;
    }
}

function draw(p: Paint): f32 {
    return p.width;
}

export function demo(): f32 {
    // The receiver is evaluated once, each section runs on it in order,
    // and the whole expression is that receiver, now 3 wide.
    let stroke = new Paint()
        ..color = 0xff0000ff
        ..width = 2
        ..thicker(1);
    // Any expression position works: arguments, returns, initializers.
    return draw(stroke) + draw(new Paint()..thicker(4)..child = stroke);
}

Dart’s cascade operator. receiver..section..section evaluates the receiver once, runs each section on it as a statement, and yields the receiver, so setting up an object field by field stays an expression. A section is a member chain rooted at the receiver, optionally followed by an assignment: ..x = v, ..x += v, ..f(a), ..[i] = v, ..a.b = v. The receiver is the whole conditional expression, as in Dart: the cascade binds looser than postfix, binary, as and ternary operators and tighter than assignment, so v as Paint..color = c casts first and x = a..b = c sets a.b and then assigns a to x; parenthesize to cascade on an assignment result. A section’s right side never starts a nested cascade: in a..child = b..width = 2 both sections apply to a, as in Dart, and a nested cascade needs parentheses. Sections may continue on following lines; the formatter puts each section of a multi section cascade on its own line.

Receivers are reference classes and interfaces: numbers, strings and function values are rejected, a nullable receiver is an error (assert with ! first), and struct receivers are rejected since structs construct by value. Number literals keep their trailing dot, so after ..x = 1 the next section goes on a new line or after a space.

Generics

Generic functions, classes, and methods are fully monomorphized at compile time, the way C++ templates are: each distinct set of type arguments compiles to its own specialized copy, so a generic call costs exactly what the hand-written version would — no boxing, no dynamic dispatch. Type arguments are usually inferred from the parameters:

generics.as
function largest<T>(a: T, b: T): T {
    return a > b ? a : b;
}

export function demo(): f64 {
    let i = largest(3, 9);        // largest<i32>, inferred from the arguments
    let f = largest(0.25, 2.5);   // largest<f64> — a second compiled copy
    return (i as f64) + f;        // 9 + 2.5
}

Compilation is lazy and whole-program, so only the instantiations code actually reaches get compiled — a combination nothing calls costs nothing. Classes and methods take type parameters the same way, and a method can add its own on top of the class’s:

box.as
class Box<T> {
    value: T;
    constructor(value: T) { this.value = value; }
    map<U>(f: (v: T) => U): Box<U> {
        return new Box<U>(f(this.value));
    }
}

export function demo(): f64 {
    let b = new Box<i32>(21);
    let scaled = b.map<f64>((v: i32): f64 => (v as f64) * 2.0);
    return scaled.value;   // 42
}

One inference gap to know: a type parameter used only in a callback’s return type is not inferred from a lambda — the lambda’s own annotations do not flow backward into the call. So arr.map<i32>(...) needs its explicit argument, while filter, reduce, and forEach — whose type parameters all appear in parameter positions — infer fine:

inference.as
export function demo(): i32 {
    let xs = [1, 2, 3, 4];
    let evens = xs.filter((x: i32): bool => (x & 1) == 0);   // infers fine
    let doubled = xs.map<i32>((x: i32): i32 => x * 2);       // needs its <i32>
    return doubled[3] + evens.length;   // 8 + 2
}