Skip to content
Language reference

Control flow#

Statements#

  • if / else if / else, while, do … while, C-style for (comma lists allowed: for (let i = 0, j = n; i < j; i++, j--)), for (const x of xs), for (const k in o), for await (const x of xs) in async code, break and continue (optionally labeled: outer: for (…) … continue outer;), return, blocks, and the ternary ?:. A body without braces (if (c) return x;) is a one-statement block.
  • Conditions take values of any type and test them as JavaScript does: 0, NaN, "" and null are falsy (truthiness).

for...of#

  • for...of iterates arrays, maps ([key, value] pairs), strings (their characters, as in JS), classes with an entries() method, and iterables: values with a [Symbol.iterator]() method returning an Iterator<T> (see below).
  • The loop variable is each element itself (objects are references): you can call its methods (including modifying ones), assign its fields, and store it elsewhere, which shares the element. Index the array to replace an element: the loop variable keeps the element it was given until the iteration ends, as in JS (for (const v of vs) { vs[0] = new V(); v.n } reads the old vs[0]). Such a loop takes a reference to each element it visits; a loop whose body does not replace elements takes none. Changing the iterated array itself in the body (push, pop, splice, length = …, assigning the variable, or passing it to a call that may change it) is a compile error (cannot modify `xs` while a `for...of` loop iterates over `xs` ): loop over indexes instead.
  • Iterating a temporary (a call result, await …, a literal) consumes it: each element is handed to the loop variable without a count.

for...in#

for (const k in o) visits the keys of an object, as in JavaScript: the same keys, in the same order, as Object.keys(o), and it is that loop (for (const k of Object.keys(o)), with the built-in Object even where a local one hides it). k is a string.

  • o is an object type, a struct or class instance or a Record. A record's keys come in insertion order; an object's fields in declaration order, a base class's first. An optional field (label?: string) is visited only while it is present. Methods are not keys.
  • The keys are listed when the loop starts: a key added during the loop is not visited. A key of a Record<string, V> deleted during the loop is not visited either, as in JavaScript, when o is a name or a property of one (r, this.scores).
  • let k makes the variable assignable in the body; the loop variable must be declared in the head (for (k in o) with an outer k is an error, as for for...of).
  • For a Map, iterate m.keys() with for...of; for...in over a number or string is an error (TypeScript's TS2407).
ts
interface Point {
  x: number;
  y: number;
  label?: string;
}

const p: Point = { x: 1, y: 2 };
for (const k in p) {
  console.log(k); // x, then y
}

Iterables#

The prelude declares TypeScript's iteration protocol; a finished result has no value:

ts
type IteratorResult<T> = { value: T; done: false } | { done: true };

interface Iterator<T, E = never> {
  next(): IteratorResult<T> throws E;
  return(): IteratorResult<T> {           // early exit; the default does nothing else
    return { done: true };
  }
}

interface Iterable<T, E = never> {
  [Symbol.iterator](): Iterator<T, E>;
}
  • for (const x of src) calls src[Symbol.iterator]() once, then next() until a result is done, binding each value (owned by the loop variable). src may be any type with that method, a class declaring implements Iterable<T> or not, or an Iterable<T> value.
  • A finished result has no value (TypeScript's has value: undefined); if (r.done) narrows r like any discriminated union. Reading r.value without narrowing gives T | null: null when r is done (TS: undefined), so g().next().value works. Destructured into const { value, done } = r, a test of done (if (done) break;, if (!done), done === false) narrows value to T as well, as in TypeScript (when T itself cannot be null). TypeScript's { value: undefined, done: true } is accepted for a finished result, without the value. console.log of a finished result prints { done: true } where Node prints { value: undefined, done: true }.
  • return() returns an IteratorResult<T>, as in TS ({ done: true }; the loop ignores it). An iterator class that releases something there writes return(): IteratorResult<T> and returns { done: true }, which also works in Node (where a return() returning nothing makes break throw a TypeError). TypeScript's it.return(undefined) (its return(value) takes the generator's return value) calls return(); another argument is an error, since a finished result carries no value.
  • Typed errors: E is what next() throws (never, the default, means nothing). The loop throws it, so a function iterating an Iterable<T, IoError> throws IoError; a generic function sum<E>(xs: Iterable<i64, E>): i64 throws E throws what its argument does.

Early exit#

Leaving the loop before done (break, return, a thrown error, or a labeled break / continue of an outer loop) calls the iterator's return() exactly once, as in JS, so an iterator holding a resource can release it. Running to the end, continue, and an error thrown by next() itself do not call it.

  • An iterator is not itself iterable: iterate the iterable that creates it (as in TS, where for...of needs [Symbol.iterator]()).
  • A generator (function*) is the short way to write an iterable: for (const x of gen(a)) over a direct call needs no iterator object at all, and a class whose [Symbol.iterator] is a generator method (*[Symbol.iterator]()) is iterable without an iterator class (so is a type whose extend block has one: it becomes an Iterable<T>). Leaving the loop early closes the generator (its finally blocks run).
  • AsyncIterator<T, E> (next(): Promise<IteratorResult<T>, E>) and AsyncIterable<T, E> ([Symbol.asyncIterator]()) are their async counterparts, iterated with for await.

Builtin iterables#

Arrays, strings, Maps and Sets (velt/collections) are Iterable<T> (Iterable<[K, V]> for a map, Iterable<string> for a string), so they convert to an Iterable<T> value and satisfy an Iterable<T> bound: sum(xs: Iterable<number>) takes [1, 2, 3], a set, m.values() or (with Iterable<string>) a string. An array literal written where an Iterable<T> is expected holds Ts. for...of over them directly keeps its own loops; only code written against Iterable<T> goes through the protocol. Converting an array to an Iterable<T> value copies no elements (the value refers to the array; at most its 24-byte header is boxed), and each loop over it creates one iterator.

  • x[Symbol.iterator]() on them returns an Iterator<T> (TS: ArrayIterator<T> and so on; the prelude classes ArrayIterator<T> and StringIterator are its implementations). An array's iterator is a live view, as in JS: it reads the length at each step, so it visits elements pushed meanwhile, and once it reported done it stays done. A string's yields characters (code points). A map's and a set's iterate the entries as of the call (JS's map and set iterators are live views).
  • for...of over a map, or over its keys(), values() or entries(), is live as in JS: entries the body adds are visited, deleted ones are not. That holds for a map in a variable, a field, a getter, an array element or a call result (for (const [k, v] of ms[0])). The map is read once, when the loop starts: assigning another map to its source in the body does not change what the loop visits.
  • IterableIterator<T, E>, IteratorObject<T, E> and AsyncIterableIterator<T, E> are TS's iterators that are also iterable ([Symbol.iterator]() returns the iterator itself, declared as Iterator<T, E>, since Velt has no covariant returns). Generators implement them and may be declared to return them (function* f(): IterableIterator<number>), and such a value converts to an Iterable<T, E> / AsyncIterable<T, E>. It does not convert to an Iterator<T, E> yet (interface values don't convert to the interfaces they extend); call it[Symbol.iterator]() for one.
  • TS's two-argument IteratorResult<T, TReturn> is IteratorResult<T> when TReturn means "nothing" (void, undefined, unknown, any); another TReturn is an error, since a finished result carries no value. The second argument of IterableIterator, IteratorObject and AsyncIterableIterator follows Generator's rules: it is dropped when it means "nothing", and is the error type E otherwise.
ts
class Countdown implements Iterator<i64> {
  n: i64;

  constructor(n: i64) {
    this.n = n;
  }

  next(): IteratorResult<i64> {
    if (this.n == 0) {
      return { done: true };
    }
    this.n -= 1;
    return { value: this.n + 1, done: false };
  }

  return(): IteratorResult<i64> {
    console.log(`stopped at ${this.n}`);
    return { done: true };
  }
}

class From implements Iterable<i64> {
  start: i64;

  constructor(start: i64) {
    this.start = start;
  }

  [Symbol.iterator](): Iterator<i64> {
    return new Countdown(this.start);
  }
}

for (const n of new From(3)) {
  console.log(n);                         // 3 2 1
}
for (const n of new From(5)) {
  if (n == 4) {
    break;                                // prints "stopped at 3"
  }
}

function total(xs: Iterable<number>): number {
  let sum = 0.0;
  for (const x of xs) {
    sum += x;
  }
  return sum;
}

const prices = new Map<string, number>([["tea", 2.5], ["cake", 4.0]]);
console.log(total([1, 2, 3]), total(prices.values()));   // 6 6.5

const it = ["a", "b"][Symbol.iterator]();
console.log(it.next());                   // { value: 'a', done: false }

Consuming an iterable#

Everything that takes the values of an array takes whatever for...of takes: a string's characters, a Map's entries (and those of a class with entries()), a Set's elements, a generator, any iterable. It uses the same loop as for...of (so a direct generator call needs no generator object, and stopping early closes the iterator):

  • Spread: [...gen()], [0, ...it, 9], and spread arguments of a rest parameter (sum(...gen()), Math.max(...values())). The values are taken in order, where the spread stands.
  • Array.from(src) and Array.from(src, (value, i) => ...) over anything for...of takes; the callback runs as each value arrives.

Array destructuring#

const [a, b] = gen() takes only the values the pattern needs, then closes the iterator (return(), a generator's finally) if it has more, as JS does; ...rest takes the remaining values, and a default applies when the iterable ended early ([x = 0]). Also in for...of heads: for (const [a, b] of rows()) over iterables of iterables, and nested: const [[a, b], [c]] = [gen(), gen()], for (const [k, [x, y]] of map). A nested pattern takes its values after the outer pattern has taken all of its own (JS goes element by element; only an outer generator with effects can tell).

ts
function* countTo(n: number): Generator<number> {
  for (let i = 1; i <= n; i++) {
    yield i;
  }
}

function* squares(n: number): Generator<[number, number]> {
  for (const v of countTo(n)) {
    yield [v, v * v];
  }
}

function sum(...xs: number[]): number {
  let total = 0;
  for (const x of xs) {
    total += x;
  }
  return total;
}

console.log([0, ...countTo(3)]);                        // [ 0, 1, 2, 3 ]
console.log(sum(...countTo(4)));                         // 10
console.log(Array.from(countTo(3), (v, i) => v * 10 + i)); // [ 10, 21, 32 ]
const [first, second] = countTo(100);                    // takes two values, then closes it
console.log(first, second);                              // 1 2
const bySide = new Map(squares(3));
console.log(bySide.get(3));                              // 9
console.log([...bySide.keys()], [..."héllo"]);           // [ 1, 2, 3 ] [ 'h', 'é', 'l', 'l', 'o' ]
const [head, ...tail] = "abc";
console.log(head, tail, Math.max(...bySide.values()));  // a [ 'b', 'c' ] 9

Arrays keep their own (faster) spread and destructuring: a spread array is copied with one allocation, and destructuring an array reads its elements by index. A string or a Map is consumed as the array of its characters or entries, as for...of iterates it.

for await#

ts
interface AsyncIterator<T, E = never> {
  next(): Promise<IteratorResult<T>, E>;
  async return(): Promise<IteratorResult<T>> {   // early exit; the default does nothing else
    return { done: true };
  }
}

interface AsyncIterable<T, E = never> {
  [Symbol.asyncIterator](): AsyncIterator<T, E>;
}
  • for await (const x of src) is allowed in async functions, async generators and a script's top-level statements (whose generated main is then async, Scripts); elsewhere it is an error that names the fix. It calls src[Symbol.asyncIterator]() once, then awaits next() until a result is done. src may be any type with that method (a class, an AsyncIterable<T> value, an async generator).
  • Typed errors: the loop rethrows what next() rejects with (E), like for...of.
  • Early exit: leaving the loop before done (break, return, a thrown error, a labeled break / continue of an outer loop) awaits the iterator's return() exactly once, as in JS; for an async generator that runs its finally blocks, which may await themselves.
  • Over a sync source (an array, an iterable, a generator) for await works like JS too: each value that is a promise is awaited (for await (const v of [load(a), load(b)])), other values are used as they are. An array of promises is consumed: its promises move into the loop, so a variable holding the array cannot be used after it.
  • for await (const x of agen(a)) over a direct async generator call keeps the generator's state inside the enclosing async function's: no allocation (see Cost).
ts
class Ticks implements AsyncIterable<i64> {
  n: i64;

  constructor(n: i64) {
    this.n = n;
  }

  [Symbol.asyncIterator](): AsyncIterator<i64> {
    return new TickIter(this.n);
  }
}

class TickIter implements AsyncIterator<i64> {
  left: i64;

  constructor(left: i64) {
    this.left = left;
  }

  async next(): Promise<IteratorResult<i64>> {
    await sleep(1);
    if (this.left == 0) {
      return { done: true };
    }
    this.left -= 1;
    return { value: this.left, done: false };
  }
}

for await (const t of new Ticks(3)) {
  console.log(t);                       // 2 1 0
}

switch#

switch has JavaScript semantics: the discriminant is evaluated once; the first case whose value equals it is entered (default when none does, wherever it is written); bodies fall through until break, return, throw or continue. continue cannot target a switch; a labeled switch can be left with break label from nested loops. Each case body is its own block scope.

Narrowing#

switch (x.kind) on a discriminated union narrows x in each case (a case reached by fallthrough sees the union of the members that can get there); switch (typeof x) narrows like typeof tests; switch (x) on a union narrows by literal member and by case null. Case values that are not literals narrow nothing.

Coverage#

As in TypeScript, a switch need not have a case for every member. The members no case takes skip it: default and, without default, the code after the switch see the value narrowed to them. A switch with a case for every member of a discriminant, a typeof, a union of literals or an enum needs no default and no code after it. With no member left in default, the value is never, so const _x: never = s; checks exhaustiveness the TypeScript way.

  • Performance: cases on tags, enums and integers dispatch through one jump table; string cases compare in order.
ts
enum Level { Debug, Info, Warn }

function describe(n: i64): string {
  let s = "";
  switch (n) {
    case 1:
      s += "one ";              // falls through
    case 2:
      s += "two";
      break;
    default:
      s = "other";
  }
  return s;
}

function tag(l: Level): string {
  switch (l) {
    case Level.Debug:
      return "D";
    case Level.Info:
      return "I";
    case Level.Warn:
      return "W";
  }
}

type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number } | { kind: "dot" };

function size(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return s.r * 2;
    case "square":
      return s.side;
  }
  return 0;                     // s is { kind: "dot" } here
}

function label(x: string | null): string {
  switch (x) {
    case null:
      return "none";
    default:
      return x;                 // x is a string here
  }
}

outer: for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    if (j == 1) {
      continue outer;
    }
    console.log(i, j, describe(i), tag(Level.Info), label(null), size({ kind: "dot" }));
  }
}

Case values#

Case values are literals (numbers, also negative ones; strings; bools), null, enum members, or any expression that === can compare with the discriminant (and compared like it: a string | null case value on a string discriminant, or the reverse; a string constant on a union of string literals; a string on a discriminant s.kind or on typeof x). A value may appear in several cases: the first one wins.

A constant of a literal type acts like the literal: it narrows and counts toward covering the members. As in TypeScript, that is a const (local or module-level) initialized with a literal and without a type annotation (const A = "a"), or a local whose type is a literal type (const y: "y" = "y"):

ts
const A = "a";

function code(k: "a" | "b"): number {
  switch (k) {
    case A:
      return 1;
    case "b":
      return 2;
  }
}

A literal that is not a possible value (case "square": when s.kind is "circle" | "rect") is an error, as in TypeScript. For typeof cases:

  • A typeof case that no member of a union has is an error (TypeScript accepts it).
  • On a type that fixes the typeof of its values (a number, string, boolean, function, class, array, ...), also a union local that flow narrowing reduced to one member (let u: number | string = 5), such a case is accepted, as in TypeScript, and never matches.
  • On any, unknown and object, cases test the run-time typeof.
  • On a type parameter, a case its type doesn't fix is an error.

Edit this page on GitHub