Variables and conditions
const and let
-
constbindings cannot be reassigned (cannot assign twice to const `x`);letbindings can. Both are block-scoped.let x: T;may be assigned later. -
let x;without a type takes its type from its first assignment, as in TypeScript (also one inside atryor anif); later assignments must have that type. Reading it, updating it (x += 1) or using it in a closure before that assignment is an error asking for a type (TypeScript readsundefinedthere), as is alet x;that is never assigned.tsfunction parse(s: string): number { let n; try { n = Number.parseInt(s, 10); } catch (e) { return -1; } return n; } console.log(parse("42")); // 42 -
An assignment is an expression whose value is the value assigned, as in TypeScript: in
a = b = nullthe value ofb = nullis thatnull, soaandbmay have different types that each take it (let a: string | null,let b: number[] | null). A chained assignment of a literal evaluates the literal for each target; of another value, the outer target gets the inner target's value read back, of the inner target's type. -
One declaration may declare several variables, as in TypeScript:
let i: number, j = 0;islet i: number; let j = 0;(at the top level, in a block and in aforinitializer). -
let x!: T(TypeScript's definite assignment assertion) declaresxaslet x: T;does. Velt still checks thatxis assigned before it is read. A variable declared without an initializer that a closure assigns (let x: T;orlet x!: T;, read in another closure or after a call asxorx!, which TypeScript does not check) is held so that each read checks it was assigned: reading it before then stops the program withTypeError: `x` was read before it was assigned(JavaScript would readundefined).x++,x += vand the other compound assignments read it the same way.tslet lo: number, hi: number = 10; let mid!: number; lo = 2; mid = (lo + hi) / 2; console.log(lo, hi, mid); // 2 10 6 -
constonly fixes the binding: modifying aconstobject or array is fine, as in JS. -
usingandawait usingdeclare aconstthat is disposed at the end of the block (Memory model). -
vardirectly in a function body (or at the top level of a module) declares whatletdoes: the variable belongs to the whole function either way. Elsewhere (in a nested block, a loop, aswitchor the head of afor), avarstill belongs to the whole function, whichletcan't express: that is reported as not supported yet; declare it withlet.tsfunction digits(n: number): string { var hi = Math.floor(n / 16), lo = n % 16; return hi.toString(16) + lo.toString(16); } console.log(digits(171)); // ab
Conditions: truthiness
Conditions (if, while, do … while, for, ?:) and the operands of !, && and ||
test values of any type as JavaScript does.
- Falsy are
false,null,undefined,0and-0(ofnumberand of every integer type:0ofi64,u8, …),NaNand"". Everything else is truthy:" ","0"and"false",Infinity, and every object, array and function (also an empty array). - A nullable (
T | nullorT | undefined) is truthy when it is neithernullnorundefinedand its payload is truthy, and the test narrows it likex != nulldoes:if (!user) return;leavesusernon-null below, andif (user && user.manager)narrows both.if (name)on astring | nullmakesnameastring(which may still be""in theelsebranch).??still replaces onlynullandundefined: forn: number | nullholding0,n ?? 5is0andn || 5is5. - An enum value is falsy when its member's value is
0or"". A union is tested by the member it holds. - A value of a generic type
Tis tested as a value of the typeTstands for: generic code is compiled for each type argument, so!xinfunction f<T>(x: T)is the number test inf(0)and the string test inf(""), with no check at run time of whatTis. voidvalues are not conditions (an expression of type `void` cannot be tested for truthiness).- A test is one comparison in the compiled code:
n != 0on an integer (also on anumberthe compiler stores as an integer),x != 0 && x == xon any othernumber(one compare once optimized),s.length != 0on a string. ||and&&return an operand, as in JavaScript:a || bisawhenais truthy andbotherwise;a && bisbwhenais truthy andaotherwise.bruns only when it is the result. The type is TypeScript's: when both sides have the same type, that type (count || 8080is anumber,name || "anon"astring), else their union (count || "none"is anumber | string).||dropsnullfrom the left side's type ((n: number | null) || 0is anumber). On a nullable object,a || bisa ?? banda && bisbornull(typeB | null:user && user.nameis astring | null). When abooleanis expected (conditions,const ok: boolean = …) or both sides arebooleans, they give aboolean.x ||= vandx &&= vassign when||or&&would take the right side.c ? a : bis typed the same way: when one branch converts to the other's type, that type, else their union (qty ? qty : "none"is anumber | string).- When one value of
c ? a : b,||,&&or??is ananyand the other is not, the result has the other value's type (c ? 1 : data.nis anumber, so(c ? 1 : data.n) + 1and(name ?? data.name).trim()work; the conversion stops with aTypeErrorwhen theanyholds a value of another type), except where ananyorunknownis expected: a: anyresult (a proxy'sgettrap returningk === "price" ? 99 : t[k]), variable, parameter or element, a field of an object literal or an element of an array literal held there, and a literal spread there ([...[c ? 1 : data.n]],{ ...{ q: c ? 1 : data.q } }) keeps theanyas it is, whatever it holds. TypeScript types all of these asany(number | anyisany); operators and built-in methods called through ananyare not supported yet, which is why the other places take the typed value's type.
function label(count: number, name: string | null): string {
if (!count) {
return "none";
}
if (name) {
return `${count} for ${name}`; // name is a string here
}
return `${count}`;
}
const port = 0;
const retries: i64 = 3;
console.log(port || 8080, retries && retries - 1); // 8080 2
console.log(port ? port : "none"); // none
console.log(label(0, "ann"), label(2, ""), label(2, "ann")); // none 2 2 for ann
const pairs = [[1, 2], [0, 5], [1, 1]];
pairs.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
console.log(JSON.stringify(pairs)); // [[0,5],[1,1],[1,2]]Module state
A module-level let is module state, as in Node: functions, methods and closures read and
assign it, and every module that imports it sees its current value (an ES live binding). Its
initializer runs once, when the program starts, in Node's module evaluation order: a module
after the modules it imports (depth first, in the order of the import statements), and the
declarations of one module in source order. One declared without a value
(let signal: AbortSignal | undefined;) starts as undefined, which its type must allow
(T | undefined, any or unknown).
let count = 0;
let current: string | null = null;
const seen = new Map<string, number>(); // not a constant value: kept like a `let`
function enter(name: string) {
current = name;
count++;
seen.set(name, (seen.get(name) ?? 0) + 1);
}
enter("a");
enter("b");
enter("a");
const add = (n: number) => {
count += n; // closures read and write the module's `count`
};
add(10);
console.log(count, current, seen.get("a")); // 13 a 2A module-level const whose value is not a constant is kept the same way (seen above), and
so is one the program uses as the one object Node evaluates once: a use takes its value over
(binds it to a name, stores it, returns it, passes it to a function that keeps it), modifies
what it holds (K.n = 1, K.push(1), passing it to a function that modifies it) or compares
it by identity (K === x). Every use then refers to that object, and a change through one name
shows through the others. A constant that is only read is evaluated where it is used instead
(a table's elements are read from read-only data), which costs nothing at startup.
A static readonly field is kept the same way, as Node evaluates it once with its class
(const v = Geo.ORIGIN; v.x = 1 changes Geo.ORIGIN.x). So is a constant or a field whose
initializer reads module state the program changes, directly or through the functions it calls
(const START = count, static readonly F = S.cur()): it holds the value from when Node
evaluated it.
type Hit = { hit: boolean; value: number };
const MISS: Hit = { hit: false, value: 0 };
const EMPTY: number[] = [];
function peek(key: string): Hit {
return key === "x" ? { hit: true, value: 1 } : MISS; // MISS itself, as in Node
}
const a = peek("y");
a.value = 7;
const xs = EMPTY;
xs.push(1);
console.log(MISS.value, a === MISS, EMPTY.length); // 7 true 1- One per process, as in Node: every task (a task started with
spawn, an HTTP request handler, a task on any core) reads and updates the same module state, so a module counter that every request handler increments counts every request. Code that uses module state runs as on Node's single thread: a task's code between twoawaits runs without any other task's code that uses module state running at the same time, socount++orseen.set(k, (seen.get(k) ?? 0) + 1)never loses an update, and what a task reads stays as it is until its nextawait. Across anawaitother tasks may change it, as in Node. Tasks run in parallel until they first use module state; from then on a task takes a process-wide lock for its code betweenawaits (cost below).
Values from other tasks
A value a task stores in module state (seen.set(k, obj)) is
then the module's, and every task that reads it gets that same object, as in Node. A value
from module state handed to spawn, sent on a channel or put into shared(...) is copied
like any value that is still referenced elsewhere
(Thread safety), since the module keeps it.
- Objects are shared, not copied:
const a = itemsrefers to the module's array, soa.push(1)shows initems, as in JS. - Imports are read-only bindings: assigning an imported
letis an error (cannot assign to imported binding `x`), as in JavaScript; export a function that assigns it. Modifying its contents (items.push(x)) is fine. - A
constwhose value is not a constant (new Map(), an array of objects, a call with effects) is kept the same way, evaluated once per process, when its module is evaluated; every task sees the same object. Assigning it is an error. A regex constant (const RE = /a+/g;) is oneRegExpobject too, so thelastIndexof agoryregex carries over from one function'sRE.test(s)to the next, as in Node. So is aconstwhose initializer reads a module-levelletor a property ofglobalThis(const first = count;): it keeps the value from when the module was evaluated, not the variable's current one. Through a function it calls (const first = getCount();) it is not kept yet. - A
constused as the one object through!,as,??,||or?:, a namespace import or a part of it is kept the same way as one used directly:K!.v = 2,(K as any).v = 2,(K ?? d).v = 2,(c ? K! : d).v = 2,const v = K!,let v = K ?? d,const a = K!.a,const { a } = K ?? d,{ ...K! }(the spread shares the objects it holds),(ns.K as any).v = 2, an argument a callee keeps (m.set("x", N.a),set.add(K?.a ?? d),new Box(N.a),xs.fill(N.a)) and a weak key (wm.set(K!.a, 1),wm.get(K!.a)). A constant holding a promise can't be module state, so those uses of one are errors (cannot move out of module constant `K`,cannot assign to a field of module-level constant `K`). On a moduleletg,g!,g as Tandg ?? dare the object the module keeps. - An initializer that throws (
JSON.parseof invalid text,new RegExpof an invalid pattern, a function that throws) stops the program withUncaught …and exit code 1, as an error thrown while Node evaluates a module does: the rest of that module and the modules that import it don't run. - An initializer that reads a
letdeclared after it (whose initializer has not run yet) is an error (`b` is read before its initializer runs), where JavaScript throws aReferenceError. A read through a function the initializer calls (also across a cyclic import) stops the program with thatReferenceError(Cannot access 'b' before initialization, exit code 1), as an uncaught one does in Node; it cannot be caught. - Module state cannot hold a promise itself (
let p = work();,let t = spawn(work());, alsoPromise<T> | null, or an object type with a promise field): it is initialized while the modules are evaluated, outside async code, where a promise does not start until something awaits it (Node starts it at once). Start the work in the program's statements (or a function) and pass the promise, or its result, where it is needed. A class, an array or aMapin module state may hold promises that the program puts there as it runs, such as a cache of requests in flight (const inflight = new Map<string, Promise<T>>()): a promise read from there on any task is another holder of the same promise (shared promises), which runs on the task that started it, and every holder gets the same result (an object result is the same object, as everything reached through module state is shared by the tasks). - A
Mutexwithcallback cannot use module state, including a constant kept there by a use elsewhere in the program, directly or through a function it calls (the function passed to `with` may use module state). Code that runs without a call counts too: field initializers atnew, and thetoString(),toJSON()and[Symbol.dispose]()methods of the values the callback handles, whichjoin, a template,JSON.stringify,usingand the end of a scope call. So does a call of a function value whose body the compiler cannot see, also one the callback hands to the standard library (a.sort(cmps[0]); a closure or a function name written there is fine).withblocks the thread while it waits for the lock, and a task holds the module lock for its code betweenawaits, so two tasks could wait for each other. Read what the callback needs beforewith, and store what it returns after. - In a root file with top-level statements, a
letthat only those statements use is a local of the generatedmain(Scripts); one that a function uses is module state, and so is one a closure uses that the code assigns or that holds a new object (so aservehandler or a test's callback may assign or change it); its initializer runs where it stands, after the statements above it, as in Node. When the rules above rule out module state for one that only closures keep there (it holds a promise, or aMutexwithcallback uses it), it stays a local ofmaininstead, with the variables below it whose initializers read it. Awithcallback that calls a function value makes every variable a closure in the script uses a local ofmain. - Cost: a program without module state pays nothing, and so does a
task that never uses it. A read or write of a module-level
let, or of a constant kept as module state, asks the runtime for the state (a constant only read is not: one use that keeps it makes every use of it pay that). A debug build asks at every access; a release build asks once per function call, plus once for each later slot (in declaration order) that the call has not checked yet, so a loop over module state does not call the runtime at each iteration. A task takes the lock at its first use and at the start of each later run betweenawaits (one uncontended atomic operation when no other task holds it). Tasks on other cores that use module state at the same moment wait for each other, so keep long computations that many tasks run in parallel out of the code between theirawaits that uses module state, or give themshared(...)state instead. A task that blocks on aMutexkeeps the module lock while it waits (theMutex's holder never needs it, see above). Aconst X = new Atomic<T>(literal)is not module state: it takes no lock and costs no runtime call (velt/sync). velt dev: code swapped into a running program starts its module state over, as after a restart (hot reload). Aconst X = new Atomic<T>(literal)keeps its value (a new one if the edit changes its type).
let a = b + 1; // error: `b` is read before its initializer runs
let b = 1;
function show() {
console.log(a, b);
}Tables
A module constant can be an array of numbers or booleans whose elements are literals: a table.
Its elements are stored once in the program as read-only data, and K[i], K.length and
for (const x of K) read them there: no array is built, and an element costs what an element of
a local array costs.
const POWERS: u64[] = [1, 10, 100, 1000];
const HEX: u8[] = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102];
const SIGNS: number[] = [-1, 0, 1];
function digits(n: u64): usize {
let count: usize = 1;
for (const p of POWERS) {
if (n >= p * 10) {
count += 1;
}
}
return count;
}
console.log(POWERS.length, POWERS[2], digits(4321), HEX[10], SIGNS.includes(0));
const mine = POWERS.slice(); // a copy is an ordinary array
mine.push(10000);
console.log(mine.length);- Each element is a number or boolean literal, also negated or converted with
as, or a module constant that is one. Any other array constant (strings, nested arrays, objects, computed elements) is not a table but module state, evaluated once per process. - Passed to a function or method that only reads it (
f(K)whenfdoesn't modify its parameter,K.includes(x),K.indexOf(x),u32x4.load(K, i)), a table is that read-only data too: nothing is built, so such calls are as cheap in a hot loop as indexing. - Used in any other way, a table is module state, like every module constant
used so: binding it to a name or a field (
const mine = K,K!,ns.Kfor an imported table), returning it, modifying it (K[0] = 1,K.push(1),K.fill(0), passing it to a function that modifies its parameter), passing it to a function value the compiler can't see into, or comparing it by identity. It is then the one array Node evaluates once, every use refers to it, and its elements are read from it instead of from read-only data.K.slice()makes a copy. - A
static readonlytable is the same:Cls.K.push(1)andconst mine = Cls.Kmake it module state.
Module constants initialized by a call
A module constant whose initializer is a call is evaluated where it is used when that call is
pure, so that evaluating it at each use gives what TypeScript's single evaluation at load
gives; any other initializer (a call with effects, a class instance, a Map) makes it
module state, evaluated once per process:
- the callee is a named function (a free function, a static method or an imported function, generic or not), not a function value or a method of a value;
- each argument is a constant expression, another module constant, a function or a closure;
- the callee and everything it calls have no loops, no recursion, no
throw, noawait, no calls through function values, and no effects (I/O, time, randomness,console,shared,spawn, external functions), and read no module state (a module-levellet, a constant kept there), which the program may change after Node evaluated the initializer: such a constant is module state itself, evaluated once. It may allocate, construct objects and create closures, ascomponentdoes below: the closures it returns run later as ordinary code; - a closure it creates keeps no mutable state: it captures no variable that is assigned, and no object, array or class instance. Otherwise the constant is module state, one closure whose state carries over from call to call, as in Node;
- the result is a value: a number, string, struct, tuple or enum. A function from a call is
kept as module state, so that it is one value as in Node, and so are class instances, arrays
and maps (a new array at each use would change what
X.push(1)does; an array of number literals is a table, which needs no call). An object or struct constant that the program takes over, modifies or compares by identity is module state, so a change through a variable or a field holding it shows in the constant, as in Node.
type Props = { start: number };
type Component = (props: Props) => string;
function component(setup: (props: Props) => () => string): Component {
return (props: Props): string => setup(props)();
}
const Counter = component((props: Props) => {
const count = props.start + 1;
return (): string => `Count: ${count}`;
});
type Point = { x: number; y: number };
function point(x: number, y: number): Point {
return { x: x, y: y };
}
const ORIGIN = point(0, 0);
console.log(Counter({ start: 1 }), ORIGIN.x); // Count: 2 0A call that does not qualify makes the constant module state: it is
evaluated once per process, where it stands in module order, as Node evaluates it once at load;
every task sees the same value.
A call that is async is an error (it is async): nothing could await it there. One
that may throw is module state too; an error it throws
stops the program while the modules are evaluated, as in Node.
A function from a call is kept the same way, so it is one value wherever it goes:
type Handler = (x: number) => number;
function adder(n: number): Handler {
return (x: number): number => x + n;
}
function load(): string {
console.log("loading"); // runs once, where DATA is declared
return "data";
}
const ADD_ONE = adder(1);
const DATA = load();
const handlers: Handler[] = [];
handlers.push(ADD_ONE);
console.log(handlers.indexOf(ADD_ONE), ADD_ONE === ADD_ONE, DATA); // 0 true dataA panic (an integer overflow, an index out of bounds) in an initializer happens at the use, where TypeScript would throw at module load.