Classes & Generics
Classes, interfaces, and virtual dispatch work like upstream AssemblyScript’s; generics are specialized to concrete types at compile time.
Classes, inheritance, interfaces
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:
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).
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
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:
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:
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:
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
}