Skip to content
Language reference

Classes, structs, interfaces and generics#

Every object has a compile-time shape (a fixed layout), so a field access is one load: no hidden classes and no runtime shape checks.

Classes#

  • Class expressions bound to a variable, const Point = class { … }; (also let, export const, with extends / implements, at module level or in a function), declare the class Point: an anonymous class takes the variable's name, as in JavaScript (console.log shows Point { … }). A class expression may have its own name only when it is the variable's (const Point = class Point { … }); another one is an error (a named class expression must have the name of the variable it initializes). Class expressions elsewhere (an argument, a return value, new (class { … })()) are not supported yet.
  • Instances are references, as in JS (Memory model): const b = a refers to the same object. x.clone() makes an independent deep copy of any class, struct or union (like structuredClone), except values owning a [Symbol.dispose] resource, which may define clone() themselves. Such a class's own clone() (no parameters, returning the class) is what every deep copy of it calls: x.clone() of an array or object holding it, and the copy a value gets when it goes to another task (Async), which then deep-copies whatever the returned object still shares with the original. A class that owns no resource is deep-copied field by field wherever it is nested; its clone() runs only when called (x.clone() on it).
  • Symbol method names: [Symbol.iterator]() and [Symbol.asyncIterator]() (the iteration protocol), [Symbol.dispose]() and [Symbol.asyncDispose]() (resource cleanup) name methods as in TypeScript and are called as x[Symbol.iterator](). Other symbols do not exist.
  • Async methods take this by value: the promise owns it.
  • An overridden method returning a promise reports its errors through the promise: when the base method or any override can fail, all of them must be async (Async).
  • A template literal calls a class's (or struct's) own toString(): string, as in JS (`total: ${price}`); without one it shows the value the way console.log does.
ts
class Account {
  static readonly LIMIT: i64 = 1000;
  readonly owner: string;
  private balance: i64 = 0;

  constructor(owner: string) {
    this.owner = owner;
  }

  get total(): i64 {
    return this.balance;
  }

  deposit(amount: i64): bool {
    if (amount <= 0 || this.balance + amount > Account.LIMIT) {
      return false;
    }
    this.balance += amount;       // modifies `this`: inferred, no annotation
    return true;
  }
}

class Savings extends Account {
  rate: f64 = 0.02;

  constructor(owner: string) {
    super(owner);
  }

  override deposit(amount: i64): bool {
    return super.deposit(amount + 1);
  }
}

const a: Account = new Savings("ada");
const ok = a.deposit(100);      // dispatched to Savings.deposit
console.log(ok, a.total, a.owner, Account.LIMIT);

Construction#

Fields#

Fields need a type (count: i64 = 0), or an initializer that states one (count = 0, done = false, items = new Map<string, i64>()). A field declared from an integer literal (count = 0) holds a JS number, like let count = 0 (Numbers). A field without a default must be assigned in the constructor.

A declare field (declare readonly code?: string;, TypeScript's field without an emitted initializer) is declared like one without declare; it cannot have an initializer.

Order of construction#

new C(…) allocates the object on the heap and constructs it in JavaScript's order: a class's field initializers run once its base class is constructed (right after super(…) returns, or after the inherited constructor when the class has no constructor of its own), in declaration order, before the rest of its constructor body. So for class D extends B:

  1. the arguments,
  2. B's initializers,
  3. B's constructor body,
  4. D's initializers,
  5. D's constructor body.

new throws whatever the initializers and constructors throw (Errors); a constructor's throws clause covers the initializers it runs (those of its class and of the classes between it and the next constructor up). Initializers may construct their own class or classes whose initializers construct it back (next: Node | null = more() ? new Node() : null); a new in such a cycle throws what every class in the cycle's initializers throws.

Before its initializer runs (for example in an overridden method that a base constructor calls) a field reads as zero or empty (0, false, "", an empty array); reading a field that holds an object there (a class instance, a Map) is unsound: nothing checks it yet.

Parameter properties#

constructor(private readonly name: string, public age: i64) {} declares the fields and assigns them, as in TypeScript (protected is accepted there and means public: there are no protected members).

As TypeScript emits them (ES2022 class fields), they are the class's first fields, before the declared ones, which decides the key order of console.log and JSON.stringify. The constructor assigns them first (after super(…)), so they are set after the class's field initializers ran:

ts
function log(s: string): i64 {
  console.log(s);
  return 1;
}
class Point {
  label: i64 = log("label");
  constructor(public x: f64, public y: f64) {
    log("constructor");
  }
}
console.log(JSON.stringify(new Point(1, 2))); // label, constructor, {"x":1,"y":2,"label":1}

Private and protected constructors#

TypeScript's rules apply:

  • private constructor(…) can be called (new C(…)) only inside the class body: its methods, static methods, field initializers and the closures in them. Such a class cannot be extended (cannot extend `C`: its constructor is private); a public static method is the usual way to create one.
  • protected constructor(…) may also be called inside the bodies of subclasses (super(…) in their constructors, or new C(…)).

Elsewhere new C is an error: the constructor of `C` is private: only the body of `C` can call `new C(...)` (or … is protected: only `C` and its subclasses can …). A subclass without a constructor of its own inherits its base's, with the same rule.

JSON.parse<C> (and v.as<C>()) cannot decode such a class, alone or inside another type, since decoding fills the fields without running a constructor; JSON.stringify writes it as usual (velt/encoding/json).

A private constructor with static factory methods, and a protected one for a base class:

ts
class Celsius {
  private constructor(readonly degrees: f64) {}

  static of(degrees: f64): Celsius | null {
    return degrees < -273.15 ? null : new Celsius(degrees);
  }

  static fromFahrenheit(f: f64): Celsius {
    return new Celsius((f - 32.0) * 5.0 / 9.0);
  }
}

class Shape {
  protected constructor(readonly name: string) {}
}

class Square extends Shape {
  constructor(readonly side: f64) {
    super("square");
  }
}

console.log(Celsius.fromFahrenheit(212.0).degrees, Celsius.of(-300.0) == null); // 100 true
console.log(new Square(2.0).name);                                              // square
// new Celsius(5.0) and new Shape("x") are errors here; so is `class Kelvin extends Celsius`.

super(…)#

The constructor of a subclass calls super(…), as in TypeScript, also when the base class has no constructor (super();). Statements may come before it as long as they don't use this or super.x ('super' must be called before accessing 'this' in the constructor of a derived class) or return; they run first, then the call's arguments, the base constructor, this class's field initializers and parameter properties, and the rest of the body. This holds also when the class has initialized fields or parameter properties, which are set right after super(…) returns, as in JavaScript (TypeScript 4.6+):

ts
class Shape {
  constructor(public name: string) {}
}
class Square extends Shape {
  sides: i64 = 4;
  constructor(size: f64) {
    if (size <= 0) {
      throw new Error("size must be positive");
    }
    const name = `square ${size}`;
    super(name);
    console.log(this.name, this.sides); // square 2 4
  }
}
new Square(2);

The call runs exactly once on every path: a statement of the constructor's body, or one in each branch of an if / else (nested ifs too), as TypeScript accepts it; not in an if without else, a try, switch, loop or closure, and not part of an expression. The field initializers run right after the call, in either branch, and every field must be initialized, so a path that skips the call or makes it twice is an error, where TypeScript only fails at run time. A class with parameter properties, or with field initializers when no base class has a constructor, needs the call as a statement of the body itself (choose its arguments with a conditional: super(c ? a : b)).

ts
class Named {
  constructor(public name: string) {}
}
class Tagged extends Named {
  tag: string = "t";
  constructor(short: boolean) {
    if (short) {
      super("s");
    } else {
      super("long");
    }
    console.log(this.name, this.tag); // s t
  }
}
new Tagged(true);

When a constructor throws (before super(…), in the base constructor or in a field initializer), new frees the object it allocated, dropping the fields that were set, without running the class's [Symbol.dispose](): like JavaScript, nothing disposes an object new never returned.

Inheritance#

class B extends A gives single inheritance. The base's fields are a prefix of the subclass layout, so upcasts are free. A subclass without a constructor of its own inherits its base's. A base class may be abstract.

A subclass may declare an inherited field again with the same type and an initializer of its own (class Dog extends Animal { kind: string = "dog" }): it is the same field, which the subclass's initializer sets after the base class's, in its turn among the subclass's initializers.

Overriding methods#

Redefining a base method requires override, except a method of a standard-library class (toString() in a subclass of Error), which TypeScript code redefines without it; super.m() calls the base version.

An override may give a parameter a default of its own: a call that leaves the argument out takes the default of the method that runs, also through a base-class type (const s: Shape = new Square(); s.scale() uses Square's), through a method value (const f = s.scale; f(), s.scale.bind(s)) and when the argument is undefined, as in JavaScript. Such a call goes through a second vtable slot whose entry in each class applies that class's own default, so a call passing every argument costs the same as a call of a method without such a default.

Dispatch#

A method that is never overridden is called directly (and can be inlined). Only overridden methods go through a vtable, and only where the static type is a base class. A call on an object whose class is known from where it was allocated (const s: Shape = new Square()) is usually made direct while the object has not been passed on.

Abstract classes#

TypeScript's rules apply: abstract class C cannot be created with new (cannot create an instance of abstract class `C` ), only its subclasses can.

  • It may declare abstract methods and accessors, abstract area(): number; / abstract get name(): string;, which have no body and must declare their return type (and take no parameter defaults: x?: T, the subclasses give the default; TypeScript's TS2371), and abstract properties, abstract readonly name: string; / abstract count: number; (an abstract getter, and a setter unless readonly).
  • A subclass implements an abstract property with a field (readonly name = "q") or accessors, and an abstract method with a method or a field of its function type (run: () => number = () => 1). Reading or writing an abstract property through this in its class's own constructor is an error (TypeScript's TS2715): no subclass field is initialized yet.
  • Every concrete subclass implements each abstract member it inherits, or it is an error naming the class and the missing members (class `Square` does not implement the abstract member `area` (from `Shape`)); a subclass that leaves some unimplemented must itself be abstract.
  • An implementation overrides the abstract method without override (writing it is allowed), and calls through the base class, also from the base's own methods and constructor, dispatch to it. abstract override m() makes an inherited method abstract again.
  • An abstract method cannot be static, private, async, a generator or generic, and cannot be called through super.
  • Abstract classes are passed as values through abstract construct signatures, abstract new () => Shape.
ts
abstract class Shape {
  abstract area(): number;
  describe(): string {
    return `area ${this.area()}`;
  }
}

class Square extends Shape {
  constructor(public side: number) {
    super();
  }
  area(): number {
    return this.side * this.side;
  }
}

const shapes: Shape[] = [new Square(2), new Square(3)];
for (const s of shapes) console.log(s.describe()); // area 4, area 9

extend methods in subclasses#

Instance methods of an extend A block apply to subclasses too: new B().m() finds m in B and its bases' bodies, then in B's extend blocks, then in each base class's, nearest first, and super.m() in B finds A's.

Not supported yet

Redefining an extend method in a subclass, in its body or in an extend block of its own. An extend method is not virtual, so a call through an A would run the extend method: `m` is an `extend` method of `A` and cannot be redefined in a subclass yet. This covers std's too: bytes() and arrayBuffer() and the body getter of Response and Request are extend methods.

Returning this#

ts
class Registry {
  names: string[] = [];
  register(name: string): this {
    this.names.push(name);
    return this;
  }
}

class Tagged extends Registry {
  tags: string[] = [];
  tag(t: string): this {
    this.tags.push(t);
    return this;
  }
}

const t = new Tagged().register("a").tag("x").register("b");
console.log(t.names, t.tags); // [ 'a', 'b' ] [ 'x' ]
  • this as a method's result type (the polymorphic this type) is the class of the object the method is called on: register called on a Tagged gives a Tagged, so chained calls keep the subclass's methods, as in TypeScript; called on a value of a type parameter bounded by the class (x.register(n) with x: T and T extends Registry), it gives a T. An override returns this too: written (override register(name: string): this) or left out, when it returns this (or super.register(name)). An override with another result type (override register(name: string): Registry) is TypeScript's error TS2416.
  • Such a method returns this (or the result of another this-returning method called on this); returning another object of the class is TypeScript's error type `A` is not assignable to type `this` . The this type elsewhere in an instance method's signature or body (a parameter other: this) is the declaring class. In a static method or outside a class it is TS2526; in an interface it is not supported yet.

instanceof downcasts#

x instanceof C on a value of a base class of C tests the object's actual class: it is true for a C and for instances of C's subclasses. Where it holds, x reads as a C, with the same narrowing rules as for unions (if/else, early returns, &&, ||, !, ternaries, until x is reassigned; a variable that a closure assigns is not narrowed, as for null). The test reads the class id in the object's vtable: one load and one comparison, whatever the depth of the hierarchy.

The same works on an interface value (a Shape holding a Circle), on C | null (true means not null) and on a union member whose class is a base of C, so catch (e) chains can tell error subclasses apart. A path of readonly fields (node.left instanceof Num) narrows too; a mutable field could change before it is read, so copy it into a local first. A field whose type is a C that may be null or undefined (o.next instanceof Node with next?: Node | null) is narrowed to C when the test is true, mutable or not, as o.next != null would (a read checks it again).

Tests joined by || narrow to the nearest class they both imply: with class S2 extends S, x instanceof S || x instanceof S2 reads x as an S, and with two subclasses T1 and T2 of S, x instanceof T1 || x instanceof T2 does too (when S is not x's own class).

  • The narrowed type keeps the type arguments: a Box<i64> tested for class Labeled<T> extends Box<T> is a Labeled<i64>. A generic class whose type arguments do not follow from the tested type is an error.
  • A test that can never be true is an error, as in a union: a instanceof Rock on an Animal when Rock does not extend Animal, or on an interface value when neither the class nor its subclasses implement the interface. (TypeScript accepts these; in Velt classes are nominal, so the answer is known.)
ts
class AppError extends Error {}

class NotFound extends AppError {
  constructor(readonly id: string) {
    super(`no item ${id}`);
  }
}

class Timeout extends AppError {
  constructor(readonly ms: i64) {
    super(`timed out after ${ms}ms`);
  }
}

function fetchItem(id: string): string throws AppError {
  if (id == "") {
    throw new Timeout(30);
  }
  throw new NotFound(id);
}

function describe(id: string): string {
  try {
    return fetchItem(id);
  } catch (e) {                   // e: AppError
    if (e instanceof NotFound) {
      return `missing ${e.id}`;
    } else if (e instanceof Timeout) {
      return `timeout after ${e.ms}ms`;
    }
    return e.message;
  }
}

console.log(describe("a"), "/", describe("")); // missing a / timeout after 30ms

Members#

Visibility#

  • public: the default.
  • private: usable only inside the declaring type's body, including closures, functions and classes declared in its methods, but not in subclasses: `x` is private.
  • #x, an ES private name: usable only in its class's body.
  • protected: only a constructor can be protected (Private and protected constructors); protected members don't exist (on a parameter property it means public).

console.log shows private fields, as Node shows a TypeScript private field, except zero-sized ones such as std's runtime markers. The standard library's own modules may use the private members of its types, which is how std types build each other's handles; this covers private only: a #x name is usable only in its class's body, in std too.

readonly fields#

A readonly field is assignable only in the constructor:

tsdoes not compile
class User {
  readonly id: number;
  constructor(id: number) {
    this.id = id; // allowed: in the constructor
  }
  reset(): void {
    this.id = 0; // error: `id` is readonly
  }
}

Static constants and fields#

static readonly NAME: T = …; constants (Account.LIMIT, Math.PI) are evaluated like module constants: once, with the class, when the program modifies, takes over or compares one, or its value is not a constant.

static fields (static count: number = 0;) are module state like a module-level let: initialized once, where the class is declared, in source order with the class's static { … } blocks. They are assigned through their class (Counter.count++, or ++this.count in a static method of Counter) and read through a subclass (Sub.count):

ts
class Counter {
  static readonly LIMIT: number = 10;
  static count: number = 0;
}
class Sub extends Counter {}

Counter.count++;                     // assign through the class
console.log(Sub.count, Sub.LIMIT);   // 1 10: read through a subclass
Not supported yet

Assigning a static field through a subclass (Sub.count = 5, or this.count = v in a static method called on a subclass) is an error: in JavaScript it creates a static field of the subclass itself. Assign Counter.count.

A class declared inside a function or block can't have static fields that are not readonly, or static blocks: JavaScript evaluates them each time the declaration runs, Velt once.

Static blocks#

static { … } blocks run once, where the class is declared, as in JavaScript, in source order with the class's static fields. A return in one is an error, as in TypeScript.

ts
class Plugins {
  static names: string[] = [];
  static {
    Plugins.names.push("default");
  }
}
console.log(Plugins.names); // [ 'default' ]
Not supported yet

this in a static block: name the class (Counter.count).

Static inheritance#

static methods are called through their class. Static and instance members are separate namespaces, as in TypeScript: a subclass's static m() doesn't hide an inherited instance method m(), and a class below it can still override that method.

Static methods are inherited, as in TypeScript: with class B extends A {}, B.f() calls A's static f() (unless B declares a static f of its own, in its body or in an extend B block), keeping its visibility (a private static one stays usable only in A's body). The statics of extend blocks are inherited the same way: B.f() looks at B's own statics, then B's extend statics, then each base class's statics and extend statics, nearest first.

A static method of a generic class is called without type arguments (Box.wrap(4) for class Box<T> { static wrap(n: number): number }): a class type parameter the static's signature doesn't use (in TypeScript statics can't use them) needs no inference, also a bounded one (class Box<T extends Named>).

this and super in static methods#

In a static method, this is the class the method was called on, as in TypeScript:

ts
class A {
  static kind(): string {
    return "A";
  }
  static hello(): string {
    return "hello " + this.kind();
  }
}
class B extends A {
  static kind(): string {
    return "B";
  }
}
console.log(B.hello()); // hello B

this.f() calls a static method and this.NAME reads a static readonly constant (own or inherited); other uses of this there are an error (`this` in a static method can only be used to call or read its class's static members). super.f() in a static method calls the base class's static f, with this unchanged.

These calls are resolved at compile time: a static method using this or super is compiled once per class below its own that can run it, so there is no dispatch at run time.

As in TypeScript, the body is typed once, with the declaring class's members: when class DogShelter extends Shelter overrides static create(): Animal with static create(): Dog, this.create() in Shelter's statics is an Animal also when they run for DogShelter (which calls DogShelter.create), and a static kind(): 2 overriding static kind(): number gives a number there. A subclass static whose type doesn't convert to the one it overrides is an error naming the subclass (`B.kind` has type `string`, which does not convert to `f64` of the member it overrides, noted in `B`'s copy of `A.run` ).

Statics as values#

A static method is also a value: const f = C.kind, xs.map(C.double) (passing it where a function with more parameters is expected calls it with the leading arguments, as for any named function). this.f passed that way in a static method is wrapped like C.f (["a"].map(this.plain)).

ts
class C {
  static double(x: number): number {
    return x * 2;
  }
}
const f = C.double;
console.log(f(4), [1, 2].map(C.double)); // 8 [ 2, 4 ]

A static method using this or super is not a value (`C.hello` uses `this` or `super`, so it cannot be used as a value): TypeScript accepts it, but JavaScript calls the function with this undefined, so this.kind() would throw a TypeError. Call it, or wrap the call: () => C.hello().

ES private names#

ES2022 private names (#x) work on fields (#count = 0, readonly #id: string), methods (#check()), accessors (get #v() / set #v(v), this.#v++ uses both) and statics (static #make(), static readonly #K = …, used as C.#make() inside the body).

o.#x names the member that the class whose body the code is in declares, on any instance of that class or a subclass, not only this. A function or class declared in a method is in the body too. Where class bodies nest, it is the innermost one declaring #x, so a nested class's own #x shadows the outer one's: o.#x on an outer-class value there is property `#x` cannot be accessed on type `Outer` within this class because it is shadowed by another private identifier with the same spelling (TypeScript's TS18014).

Elsewhere, also in a subclass, it is an error (property `#x` is not accessible outside class `A` because it has a private name), also for an accessor: reading o.#v, assigning o.#v = 1 or updating o.#v += 1 reports it once.

A subclass may declare its own #x: a second field, not a redeclaration, and a base class method keeps using the base's. A #m method is never virtual (a direct call) and overrides nothing (override #m is an error).

#x in o is a brand check: it is o instanceof C, C the class declaring #x, and narrows like instanceof. As in TypeScript, o must not be possibly null (the right operand of `in` may be null; JavaScript throws a TypeError for null): test o !== null && #x in o. Nor may it possibly be a primitive (A | string: the right operand of `in` may be a primitive, TypeScript's TS2322); narrow it first (typeof o !== "string" && #x in o). A union of objects (A | number[]) is fine.

Private names exist only in class bodies: not in interfaces, object types, structs, extend blocks or parameters, and not with private / public. At run time #x and private x cost the same (an ordinary field slot, a direct call); they differ where Node differs:

#xprivate x
console.log, template literalshidden: A { y: 2 }shown
JSON.stringifyskippedwritten
JSON.parse<C>error: decoding cannot set iterror, the same
Object.keysskippedlisted
{ ...o }skippedcopied, unless private to another type
x.clone()copiedcopied
name space#x and x coexist; a subclass may reuse #xone name per class chain
ts
class Counter {
  #count = 0;
  label: string = "clicks";
  inc(): number {
    this.#count++;
    return this.#count;
  }
  static isCounter(o: Counter | string): boolean {
    return typeof o !== "string" && #count in o;
  }
}
const c = new Counter();
c.inc();
const n = c.inc();
console.log(c, n, JSON.stringify(c)); // Counter { label: 'clicks' } 2 {"label":"clicks"}

Getters and setters#

get size(): T { … } is read as a property (x.size) and cannot be called or assigned; set size(v: T) { … } runs on x.size = v. A getter's type may be inferred from its return like a method's (Return types); an interface getter writes it. Getters cannot be static or async.

With both, x.size += 1, x.size++ and x.size ??= v (also ||=, &&=) use both, as in JS: x is evaluated once, then the getter runs, then the right-hand side, then the setter (which ??=, ||= and &&= skip when the old value decides). The setter runs on the object x named before the getter and the right-hand side ran, even when one of them assigns another one to x. A getter may change its object (a signal recording who read it).

As a value, x.size = v is v (converted to the setter's parameter type, but non-null when v is; the getter is not read again; an object is shared, not copied) and x.size ??= v is the old value where it decides, else v (so non-null when v is).

Implementations and overrides of a getter or setter must be accessors too. An override of only the getter of a base class's get/set pair replaces the whole property, as in JS: writing it through the subclass is an error (TypeScript's TS2540), and through the base class, an interface or an intersection it stops with Node's TypeError: Cannot set property n of #<S> which has only a getter. Overriding only the setter is an error: the setter-only accessor would hide the base getter (reads give undefined in JS), so override the getter too.

Structs#

struct declares an object type with the same members as a class (methods, getters, private, static). It is built with a struct literal Point { x: 1, y: 2 } or an object literal where a Point is expected. A struct has no constructor and no inheritance (implements is allowed). Like every object it is a reference (Memory model): assigning or passing it shares it. The compiler stores it inline (no heap allocation, no count) as long as the program never shares a value of the type, or never changes one in place. == compares structs by identity, as for every object, but Map and Set keys of a struct type compare and hash by content, field by field (Map).

Planned: Velt is to have no struct keyword: plain data will be type Name = { … } with extend Name { … }, and everything else a class.

ts
struct Vec2 {
  x: f64;
  y: f64;

  len(): f64 {
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }
}

let p = Vec2 { x: 3.0, y: 4.0 };
const q = p;                    // the same object
p.x = 0.0;
console.log(p.len(), q.len());  // 4 4

Interfaces#

  • An interface lists the methods, getters, setters and fields an implementing type must have, and may give methods default bodies (mixins). class C implements I, J takes any number; interface B extends A, C inherits everything.
  • An interface with methods (or getters or setters, own or inherited) is nominal: a type implements it only by declaring implements (or through an extend block), or by being an object literal where it is expected (Object literals implementing interfaces).
  • Used as a generic bound (<T extends Named>), an interface is resolved at compile time (direct calls). Used as a value type (Named[] holding different classes), it is a fat pointer (data plus vtable), like Rust's dyn.
  • An optional method m?(x: T): R is an optional field of function type (m?: (x: T) => R): an implementing class declares the field as m?: (x: T) => R or m: ((x: T) => R) | undefined = undefined, and a caller writes o.m?.(x).
  • A default body can be async (async load(): Promise<T> { … }), with the rules of an async class method (Async). A method without a body cannot be: like in TypeScript, it declares a Promise result, and implementations may be async.
  • A method without a body declares that a parameter may be left out with x?: T; a default value (x: T = 1) is an error, as in TypeScript (a parameter initializer is only allowed in a function or constructor implementation): each implementation gives its own (step(by: number | undefined = 1) in the class). A default body may have defaults, as any method.
ts
interface Named {
  name(): string;
  greet(): string {
    return `hi ${this.name()}`;           // default method
  }
}

class Dog implements Named {
  name(): string {
    return "rex";
  }
}

class Cat implements Named {
  name(): string {
    return "tom";
  }

  greet(): string {
    return "meow";
  }
}

function loudest<T extends Named>(x: T): string {   // monomorphized: no vtable
  return x.greet().toUpperCase();
}

const pets: Named[] = [new Dog(), new Cat()];       // interface values: dynamic dispatch
for (const p of pets) {
  console.log(p.greet());
}
console.log(loudest(new Dog()));

Interfaces with only fields#

An interface with only fields (at least one, no methods, extending only such interfaces) describes data: it is an object type, like type User = { … }, so object literals satisfy it, field reads are loads and it has a JSON form. It converts to and from object types with the same fields and stays the same object.

class C implements User checks that the class has the fields, but a class instance is not a User (it is shared by reference; build one from its fields). As a bound, <T extends HasId> is satisfied by any type with the fields (a struct, class or object type, not generic).

An interface may refer to itself through a field (next?: Node, parent?: Category, or a union such as left: Tree): such an object is stored behind a pointer, like a class instance, so its size is finite. Like JavaScript, JSON.stringify of an object that contains itself (n.next = n) fails: it panics with "converting circular structure to JSON"; console.log prints one like Node, <ref *1> { value: 1, next: [Circular *1] }. A struct that contains itself is still an error (recursive type `S` has infinite size): structs are stored inline.

ts
interface User {                          // only fields: an object type
  readonly id: i64;
  name: string;
  email?: string;
}

interface HasId {
  id: i64;
}

function idOf<T extends HasId>(x: T): i64 { // any type with an `id: i64` field
  return x.id;
}

const u: User = { id: 1, name: "ann" };   // an object literal satisfies it
console.log(JSON.stringify(u), idOf(u));

Fields through an interface#

Fields are read and written through an interface value or a T extends I as in TypeScript: hook.owner = null, sub.flags |= DIRTY, sub.runs++, sub.dep ??= d. The write changes the object behind the value (a struct converted to an interface value is a copy, as structs always are); a readonly field cannot be assigned (cannot assign to `id`: it is a readonly field).

Reads go through a getter in the interface's table and writes through a setter that only interfaces the program writes through get, so other programs pay nothing; through a T extends I both are direct calls. An object literal implementing such an interface with get f() must give set f(v) too. A class implements an optional field x?: T with a field x?: T or, as in TypeScript, with a plain x: T: read through the interface it is present, and writing undefined through the interface stops with a TypeError (the class's field holds no undefined).

Changing a part of a field (x.t[0] = 5 on a tuple field, x.pos.p[1] += 1, x.t[0]++) changes the field the object holds, as in JS: the field is read through its getter, the part changed and the field written back through its setter. The receiver is evaluated once, and a right-hand side with an effect (a call, an await) runs before the field is read, so a change it (or another task) makes to the same field is kept.

readonly is shallow, as in TypeScript: x.o.v = 5 and x.t[0] = 5 on a readonly field work, written back through a setter the interface keeps for this; an object literal that implements the field with only get o() then needs set o(v) too.

ts
interface Hook {
  currentOwner: number | null;
  nextId(): number;
}

class DevHook implements Hook {
  currentOwner: number | null = 7;
  nextId(): number {
    return 1;
  }
}

function withoutOwner(h: Hook, f: () => void): void {
  const prev = h.currentOwner;
  h.currentOwner = null;                  // through the interface value
  f();
  h.currentOwner = prev;
}

const hook = new DevHook();
withoutOwner(hook, () => console.log(hook.currentOwner)); // null
console.log(hook.currentOwner);                           // 7

Generic methods#

Generic methods (apply<U>(f: (x: i64) => U): U[]) are called on a concrete class, on a T extends I generic (resolved for each instantiation, also when an interface value stands for T), or on an interface value: there the method's table entry is itself a table, with one entry per list of type arguments the program calls it with, compiled for every class implementing the interface. A generic method calling itself through an interface value with a growing type argument (r.go([x]) in go<T>(x: T)) has infinitely many instantiations and is an error, as for a direct call.

ts
interface Mapper {
  apply<U>(f: (x: number) => U): U[];
}

class Two implements Mapper {
  apply<U>(f: (x: number) => U): U[] {
    return [f(1), f(2)];
  }
}

const m: Mapper = new Two();
console.log(m.apply((x) => `${x}`), m.apply((x) => x * 10)); // [ '1', '2' ] [ 10, 20 ]
Not supported yet

Converting a class to such an interface where a subclass overrides the generic method (a Base that may hold a Sub would run Base's method; converting a new Base() or the Sub itself is fine), also when the value comes from any, unknown or object (which keep the type it had there), and converting an interface value with a generic method to other type arguments (H<number> as H<unknown>). Both are errors where the program calls that generic method through interface values. Generic interface methods cannot have default bodies yet.

The method that runs is the one of the class the type parameter is given: when a subclass of that class overrides it, a value of the class may hold the subclass, so instantiating the generic with that class is the same error as calling the method on it directly (pass the concrete class), and an interface value cannot stand for such a type parameter.

A call that passes exactly that class is allowed: a new C() given directly to a parameter of type T of a function or of a generic method, or a const initialized with new C(). Other variables, a value inside another parameter's type (get: () => T, xs: T[]), a default (y: T = x) and a class instantiated with C (new Holder<C>(new C())) don't count.

Declaration merging#

As in TypeScript, an interface declared again in the same module is one interface with the members of every declaration: the first declaration's members first, then each later one's. declare module "spec" { interface X { … } } adds members to the interface X that module declares (also when it re-exports it from another module), wherever the augmentation is: the interface has the same members in every module. declare global { interface X { … } } merges into an interface X of its own module, or declares one there.

ts
interface Point {
  x: number;
}
interface Point {
  y: number;
}

const p: Point = { x: 1, y: 2 };
console.log(p.x + p.y); // 3
  • The declarations' type parameters must have the same names (TypeScript's TS2428), all of them are exported or none (TS2395), and their extends lists are joined.
  • A member declared in two declarations is an error. TypeScript allows a repeated field of the same type, and makes a repeated method an overload.
  • An interface merges only with interfaces, not with a class or enum of its name. Next to a function or constant of its name it is that name's type, the function its value (Types and values of one name).
  • var NAME: T; in declare global declares a build-time constant (see define).
  • A namespace of types in declare global is declared in its module, as a declare namespace (Namespaces), like a declare global interface.
  • Not supported yet: other declarations in declare global and declare module blocks (functions, namespaces in declare module, var in declare module), declaring a new interface in another module with declare module, a declare global interface visible in other modules, and a shorthand declare module "spec";.

Generics#

Classes, structs, interfaces and functions take type parameters (class Stack<T>, interface Box<T>, function f<T extends Comparable<T>>). Every instantiation is compiled separately (monomorphization): no boxing, and bounds resolve to direct calls. Bounds are interfaces (an interface with only fields is satisfied by any type with its fields; an interface value by its own interface and the ones it extends, so f<Person> fits function f<T extends Named> with interface Person extends Named, and its methods are called through the value's table) or, as in TypeScript, other types a type argument must be assignable to (T extends Record<string, V>, T extends string | number; Generic functions).

A class bound (T extends Base, E extends Error) is satisfied by the class and its subclasses (classes are nominal: another class with the same fields is not one), and inside the generic body a T has the class's fields, getters, setters and methods, and converts to the class (const b: Base = x). Each instantiation reads them from its own class: an overriding method runs, and a T returned keeps the subclass's type (touch(new Sub()).extra).

ts
class Shape {
  name: string = "shape";
  area(): number {
    return 0;
  }
}

class Square extends Shape {
  side: number = 2;
  override area(): number {
    return this.side * this.side;
  }
}

function label<T extends Shape>(s: T): T {
  s.name = `${s.name} of ${s.area()}`;
  return s;
}

class NotFound extends Error {}

function describe<E extends Error>(e: E): string {
  return `${e.name}: ${e.message}`;
}

const sq = label(new Square());
console.log(sq.name, sq.side);                    // shape of 4 2
console.log(describe(new NotFound("no user")));   // Error: no user

Type parameters may have defaults (interface Iterator<T, E = never>), used when a type leaves the argument out (Iterator<i64> is Iterator<i64, never>) and when nothing infers it in a new or a static method call (new D(1) of class D<T = i64> is a D<i64>); functions and methods take them too (Generic functions). As in TypeScript, a default may use only the parameters declared before it, a parameter without a default may not follow one with a default, and a default must not need its own declaration's defaults again (type parameter `T` has a circular default for class S<T = S>).

ts
class Stack<T> {
  items: T[] = [];

  push(x: T) {
    this.items.push(x);
  }

  pop(): T | undefined {
    return this.items.pop();
  }
}

const s = new Stack<string>();
s.push("a");
s.push("b");
console.log(s.pop() ?? "empty", s.items.length);

extend#

An extend block adds methods, getters, setters and static methods to an existing type, builtins included, at zero cost (the calls are direct):

  • Any type: extend string, extend i64, extend Array<string>, classes, structs and unions (including discriminated unions: extend Shape { area(): f64 { switch (this.kind) … } }).
  • Generic and blanket extensions: extend<T> Array<T> { … }, extend<T extends Named> T { … } (every implementor gets the method).

Function types#

extend<R> (() => R) { … } applies to every function without parameters (closures, named functions, values of a function type), extend<A, R> ((a: A) => R) to those with one, and extend<A, R> ((...xs: A[]) => R) to those with a rest parameter only. A function type written without throws in a target fits a function that throws anything: it has a type parameter of its own for that, so this in the block is the receiver with its error type, and calling this() throws what the receiver throws. That parameter has no name in the block (a type E in scope keeps its meaning there); messages show it as E, or as E1, E2, … when the block, one of its methods or its scope has a type of that name, and hovers leave it out of the method's type parameters. To name it, write the parameter (extend<R, E> (() => R throws E)); a fixed error type (() => R throws Error) is an error. A function type written without throws in a method's signature can't hold this (self(): () => R { return this; } is an error: expected () => R, found () => R throws E), as a function type's error type parameter isn't assignable to such a site anywhere; write the parameter and return () => R throws E. A function type in a type alias the target names (extend<R> Thunk<R>) or inside another type (extend<R> Array<() => R>) is matched the same way. A generic function's value is not of a function type, and gets only blanket blocks.

ts
extend<R> (() => R) {
  twice(): R[] {
    return [this(), this()];
  }
}
let n = 0;
const next = () => ++n;
console.log(next.twice()); // [ 1, 2 ]

Static methods#

extend Point { static origin(): Point { … } } is called as Point.origin(). A static method of a primitive type (i8 … u64, isize, usize, f32, f64, bool, string) is called through the type's name, f64.half(3), unless the program declares a value of that name. As for a class, the static is a value too (const h = f64.half;, f64.half?.(2)), and this in such a static is the type (this.half(x) is f64.half(x)). The name is no value otherwise (const t = f64; is an error), and f64.m() of a method that is not static is an error naming it. Blanket extensions (extend<T extends I> T { static … }) give no statics to primitive types, as they give none to classes.

ts
extend f64 {
  static half(x: f64): f64 {
    return x / 2;
  }
}
console.log(f64.half(3)); // 1.5
  • A type's own member wins over an extension. Among the extensions that apply (target matches, bounds hold), the most specific wins: block A is more specific than block B when A's target is an instance of B's and not the other way round, so extend Array<string> wins over extend<T> Array<T[]>, which wins over extend<T> Array<T> (the prelude's join on string[][] is its own). With the same target, a block with bounds (extend<T extends Comparable<T>> Array<T>) wins over one without. When no single block is more specific than every other one (extend<T> Map<string, T> and extend<K> Map<K, i64> on a Map<string, i64>, or two blocks with the same target that both have bounds or both have none), a call is an error: ambiguous extension method `m` , naming both blocks.
  • private is not allowed in extend, and an extension cannot add fields (the layout is fixed).
  • A type becomes Comparable by defining compareTo in an extend block (Comparable), and Iterable<T, E> by defining [Symbol.iterator]() returning an Iterator<T, E>, or as a generator method (*[Symbol.iterator](): Generator<T, E>); [Symbol.asyncIterator]() makes it an AsyncIterable<T, E> (Iterables). A generator method there that throws must write its error type in its result (Generator<T, E>): the interface's type is fixed before bodies are checked.
  • Scope: an extension applies wherever its module is loaded; extend blocks cannot be exported.
  • Planned: static readonly constants in extend; module scoping (an extension is visible where it is imported) with ambiguity errors.
ts
type Shape = { kind: "circle"; r: f64 } | { kind: "square"; side: f64 };

extend Shape {
  area(): f64 {
    switch (this.kind) {
      case "circle":
        return Math.PI * this.r * this.r;
      case "square":
        return this.side * this.side;
    }
  }
}

extend string {
  get initial(): string {
    return this.slice(0, 1).toUpperCase();
  }
}

extend<T> Array<T> {
  count(): i64 {
    return this.length as i64;
  }
}

class Point {
  x: f64 = 0.0;
  y: f64 = 0.0;
}

extend Point {
  static at(x: f64, y: f64): Point {
    const p = new Point();
    p.x = x;
    p.y = y;
    return p;
  }
}

function describe(s: Shape): string {
  return `${s.kind} ${s.area()}`;
}

console.log(describe({ kind: "square", side: 2.0 }), "velt".initial, [1, 2, 3].count(), Point.at(1.0, 2.0).y);

extend … implements#

extend Target implements I, J { … } makes the block's methods implement interfaces for every type the target matches, built-in types included: the type then satisfies I as a bound and converts to an I value, as a class with implements I does. The block is checked as a class is: each method of I must be in it with the same parameter and result types (a static method does not count), and a method I gives a default for may be left out.

  • Blanket blocks (extend<T extends Comparable<T>> T implements Describe) give the interface to every type that meets their bounds, type parameters included: in function f<K extends Comparable<K>>(k: K), k.describe() works and k meets a T extends Describe bound (show(k) with function show<T extends Describe>(x: T)). A value of a type parameter does not convert to a Describe value, whatever its bounds.
  • A type's own implementation comes first: a class that says implements Describe, or inherits it from a base class, keeps its own methods, and a blanket block applies to the types without one. A block for a type that already implements the interface is an error.
  • A type implements an interface once, with the arguments of the block that wins: two blocks for W implementing G<i64> and G<string> conflict, and with extend<T> Box<T> implements G<T> and extend<A> A implements G<i64>, a Box<string> is a G<string> only. A block with bounds must give the same arguments as the blocks it wins over (those it is more specific than, and those for a base class of a type it fits): with extend<T extends Q> T implements G<i64> and extend<T> T implements G<string>, whether a type is a G<string> would depend on whether it is a Q, so the pair is refused.
  • Blanket blocks chain: with extend<T extends K> T implements J and extend<T extends J> T implements I, a T extends K in generic code is an I too, through chains of any length.
  • Interface values meet bounds on their interface and the ones it extends: with extend<T extends P> T implements Q, a P value (or a P2 value, interface P2 extends P) is a Q, and so is a Box<P> with extend<T extends P> Box<T> implements Q.
  • A block's bounds hold when the type meets them at all, through its own implementation or another block: with extend<T extends Q> T implements P, extend<T> T implements P and extend<T extends P> T implements Q, every type is a P and a Q, and gets the bounded P block. A type never meets a bound only through itself.
  • The most specific block wins for each other type, by the rule above: with the blanket block and extend u64 implements Describe, a u64 uses the u64 block everywhere, in generic code too (f(5 as u64) calls it through K), and every other comparable type uses the blanket one. Generic code is compiled for each type it is used with, so the call is direct: specializing a generic function or class for some types costs nothing at run time.

Errors#

A missing or mismatched method; two blocks for one interface that can both fit a type where neither is more specific (conflicting implementations of `I` , naming a type both fit: extend<A> Pair<A, i64> and extend<B> Pair<i64, B> both fit Pair<i64, i64>), in one module or in two; a block with bounds that wins over another with other interface arguments (the block for `G<i64>` has bounds and takes precedence over the other); a block for an interface the type already implements (`P` already implements `I`, also for an interface type and an interface it extends); an interface with fields or optional methods (cannot implement `I`: `I` declares fields: an extend block cannot add fields); a target that is any, unknown, never or a union (a union's value has one member's type: implement the interface for each member); a blanket block bounded by the interface it implements (extend<T extends I> T implements I), which no type could get; a bound of any, unknown, object or Function on a block that lists implements (leave out a bound every type meets). Blocks whose bounds only lead to each other (extend<T extends I> T implements J with extend<U extends J> U implements I) give a type neither interface unless it has one of its own.

  • A value converted from any or unknown to I uses the implementation a direct conversion of the value would.
  • extend X implements Comparable<X> may be written out too; a block that only defines compareTo or [Symbol.iterator]() implements Comparable or Iterable without it (above).
ts
interface Describe {
  describe(): string;
}

extend<T extends Comparable<T>> T implements Describe {
  describe(): string {
    return "a comparable value";
  }
}

extend u64 implements Describe {
  describe(): string {
    return `the u64 ${this}`;
  }
}

function label<K extends Comparable<K>>(k: K): string {
  return k.describe();
}

const items: Describe[] = [7 as u64, "x"];
console.log(label(5 as u64), label("abc"), items.map((d) => d.describe()));
// the u64 5 a comparable value [ 'the u64 7', 'a comparable value' ]

Comparable#

The prelude declares interface Comparable<T> { compareTo(other: T): i64; } and implements it for every number type, string (UTF-16 code-unit order, as <) and bool. With T extends Comparable<T>, the operators <, <=, > and >= work on T (static dispatch after monomorphization), and sort() orders Comparable elements (floats put NaN last). User types implement it with implements Comparable<X> or an extend block, and then <, <=, > and >= work on their values too (v1 < v2 is v1.compareTo(v2) < 0).

ts
class Version implements Comparable<Version> {
  major: i64;
  minor: i64;

  constructor(major: i64, minor: i64) {
    this.major = major;
    this.minor = minor;
  }

  compareTo(other: Version): i64 {
    return this.major != other.major ? this.major - other.major : this.minor - other.minor;
  }
}

function max<T extends Comparable<T>>(xs: T[]): T | undefined {
  let best = xs.pop();
  for (const x of xs) {
    if (best !== undefined && x > best) {
      best = x;
    }
  }
  return best;
}

console.log(max([3, 9, 2]), max(["pear", "zoo"]), max([new Version(1, 2), new Version(1, 10)])?.minor);

Edit this page on GitHub