Types
| Type | Meaning |
|---|---|
i8 i16 i32 i64 isize, u8 u16 u32 u64 usize | fixed-width integers |
f32 f64; number | floats; number is f64, a JavaScript number (Numbers) |
boolean, bool | true / false; one type with two names (Booleans) |
string | immutable text, indexed in UTF-16 code units (stored as UTF-8), a value (Strings) |
symbol, unique symbol | unique values compared by identity, also naming members (Symbols) |
u8x16, i32x4, f32x4, f64x2, …; mask8x16, … | 128-bit SIMD vectors and the masks comparing them gives (SIMD vectors) |
void, never | no value; no possible value (switch) |
bigint | no values yet: never until Velt has BigInt values, so string | bigint is string and T extends bigint ? A : B is B (bigint) |
T[] | growable array |
[A, B] | tuple |
Map<K, V> | insertion-ordered hash map |
{ a: T; b: U } | anonymous object type |
| classes, structs, interfaces | Classes |
A | B, T | null | unions, nullable values |
A & B, T["k"] | intersections of object types, branded types, indexed access |
"up", 42, true | literal types |
(x: T) => U | function values: closures and named functions (Functions) |
Promise<T>, shared<T>, Mutex<T> | async results and thread-safe shared values (Async) |
Types are required on function parameters. A missing return type is inferred from the
function's returns (Return types), and everything else is
inferred too. type Name = … declares an alias; it is checked even where nothing uses it. An
alias may refer to itself only when it is an object type, or an intersection of object types,
written out (type Tree = { kids: Tree[]; v: number }): it is then the interface with those
fields (Intersection types). An alias or interface may share its name
with a constant or function, as in TypeScript
(Types and values of one name). any
and unknown hold a value of any type, checked at run time (any and unknown);
dynamic JSON is JsonValue (velt/encoding/json).
The names TypeScript does not have (i8…i64, isize, u8…u64, usize, f32, f64,
bool) are like library globals: a type alias, class, interface or enum of the module with
one of these names, or one it imports, takes their place there, as a local Map would in
TypeScript. So TypeScript code that declares type u8 = number compiles and behaves as in
Node. A module that declares none of them has Velt's types. TypeScript's own names (number,
boolean, string, …) cannot be declared.
Booleans
boolean and bool are the same type, and either name can be used anywhere a type is written:
annotations, generic arguments, unions and function types. boolean is TypeScript's name, so
code that is shared with TypeScript uses it; bool is Velt's shorter name.
Diagnostics, hover and inlay hints print boolean; velt fmt keeps the name you wrote.
function isEven(n: i64): boolean {
return n % 2 == 0;
}
const check: (n: i64) => bool = isEven;
const flags: (boolean | null)[] = [check(2), null];
console.log(flags); // [ true, null ]Numbers
number is a JavaScript number, always: an IEEE-754 double, with -0, NaN, ±Infinity and
rounding past 2^53, exactly as in Node. The compiler stores a number as a 32- or 64-bit
integer where it proves the result is identical (loop counters, indexes, % m sums, the result
of | 0), so integer code runs at integer speed without changing what it computes.
velt build --report numbers lists the number variables in loops that stay doubles, and why.
Integer types (i8..i64, u8..u64, isize, usize) and f32 are Velt's opt-in for speed
and memory where you want a fixed width; code shared with TypeScript never names them.
Literals
7 and 0.5 are numbers. Where an integer type is expected, a literal takes it:
const n: i64 = 7, n + 1 with n: i64, f(2) with an i32 parameter, 300 as u8 (44).
A suffix states the type: 7u8 is a u8. A module-level const N = 4 stands for its literal
wherever it is used (TS types it 4): i < N with i: i64 compares integers. shared(0) is
a shared<i64>, an atomic counter.
- Locals declared from literals:
let i = 0is a number unless the function usesionly as one integer type (Locals declared from literals).
The standard library hands you numbers
Its JavaScript API (the globals Array,
String, Map, Date, Math, fetch, URL, …) gives numbers where JavaScript does:
xs.length, s.indexOf(t), m.size, Date.now(), res.status and the indexes of
entries() and of array callbacks (inside the standard library they stay usize/i64).
Velt's own modules (velt/db, velt/hash, velt/math, …) and the methods JS has no
counterpart of (compareTo, leadingZeros, trailingZeros, countOnes) keep the integer
types they declare, as do a program's and its packages' own functions and extend methods.
Where an integer type is expected, the standard library's integers convert by themselves
Such a number is an integer below 2^53, so it converts implicitly to an
integer type that holds every value it can have, as the integer itself (no conversion
happens at run time). A length or a size goes into usize, u64, i64 or isize, and an
index that can be -1 (indexOf) into i64 or isize. That covers a declared local, a
parameter, a field, a return value or an explicit type argument, arithmetic, a shift or a
compound assignment next to such an integer, and arithmetic with a literal where such an
integer is expected (at(xs, xs.length - 1)). Otherwise it stays a number, as in
TypeScript, also next to a local declared without a type:
function at(xs: string[], i: usize): string {
return xs[i];
}
const xs = ["a", "b", "c"];
const n: usize = xs.length; // 3
let depth: usize = 1;
depth += xs.length; // 4
console.log(n, depth, at(xs, xs.length - 1)); // 3 4 c
console.log(xs.length / 2); // 1.5: a numberNarrower types still need as (xs.length as u32), and so does usize for indexOf. A
number passed to an integer parameter of a standard library
function converts like JS's ToIntegerOrInfinity (truncated; NaN is 0):
xs.slice(0, xs.length / 2) takes the first half. A callback that the standard library
expects to return a number, such as a sort comparator, may return an integer of any type:
items.sort((a, b) => a.id - b.id) with id: i64.
Integers next to numbers
i8..i32, u8..u32 and f32 convert to a number
implicitly, because every value converts exactly (k * 0.5 with k: i32). i64, u64,
isize and usize need as number, and a number going into a declared integer needs as T
(n as usize). Comparisons are exact: i < xs.length with i: usize compares the integers.
Declared integers keep Rust semantics
They wrap at their width, / truncates
(7i64 / 2 is 3), / 0 and % 0 panic, ** is integer power, and % takes the sign of the
dividend. On numbers, / 0 is Infinity, % 0 is NaN and -4 % 2 is -0, as in JS.
Math.trunc(a / b) on integers is one integer division.
asconverts between number types with Rust semantics, and is TypeScript's type assertion for every other type (as).- A float index (
xs[i]withi: number,xs[Math.floor(n / 2)],xs[parseInt(s)]) must be a whole number at run time; anything else panics like an index out of bounds (JS readsundefined). Indexing with a quotient directly,xs[n / 2], stays an error: writeMath.trunc(n / 2).
Bitwise operators on numbers are JS's 32-bit operators
| & ^ << >> ~ take ToInt32 of
their operands (truncate, then wrap modulo 2^32 into the signed 32-bit range; NaN and
±Infinity give 0) and >>> takes ToUint32; shift counts are taken modulo 32, and the result is
a number: (a / 13) | 0 truncates, -1 >>> 0 is 4294967295, 1 << 32 is 1. A product
inside such an operand rounds like JS's double multiply past 2^53, so (y * 0x2c1b3c6d) | 0
is Node's value; Math.imul(y, 0x2c1b3c6d) is the 32-bit wrapping product (one instruction).
They compile to 32-bit integer instructions. Operands of a declared integer type keep their
own width (n >>> 3 with n: i64 is a 64-bit shift), and so does a constant of two literals
where an integer type is expected (const m: u64 = 1 << 40).
- Printing follows Node:
10,1.5,0.30000000000000004,1e+21,NaN,Infinity.console.log(-0)prints-0(also inside arrays and objects);`${-0}`,String(-0),(-0).toString()andJSON.stringify(-0)give0.
const a = 7; // a number
console.log(a / 2, a + 0.5, -a * 0); // 3.5 7.5 -0
const n: i64 = 7; // a declared integer: integer arithmetic
console.log(n / 2, Math.trunc(a / 2)); // 3 3
let small: u8 = 250;
small += 10; // wraps: 4
console.log(small, n as number / 2, 300 as u8); // 4 3.5 44
const h = 0x12345678;
console.log((h * 0x2c1b3c6d) | 0, Math.imul(h, 0x2c1b3c6d), -1 >>> 0); // -1019940576 -1019940584 4294967295
function collatz(start: i64): i64 {
let x = start;
let steps = 0; // returned as an i64: an i64
while (x != 1) {
x = x % 2 == 0 ? x / 2 : 3 * x + 1;
steps++;
}
return steps;
}
console.log(collatz(27)); // 111Locals declared from literals
let i = 0 declares a number, unless the function uses i only with one integer type T and
never as a number; then i is a T, as if declared let i: T = 0. So let steps = 0; … return steps in a function returning i64 counts in an i64, and let i = 0; i < n with n: i64
makes i an i64.
- A use with
Tis assigning, passing, returning or storing the local (or arithmetic on it:acc + i) where aTis expected, or combining or comparing it with a 64-bit integer of typeT. - A use as a number is combining it with another number (
0.5,xs.length),/,**, anumberparameter or a method call on it, and a bitwise operator unless the other operand is a 64-bit integer (x << 40shifts by 8, as in JS).
Arithmetic (+ - * %, -x, +=, ++) on a local allows only a 64-bit T: let n = 200; takeU8(n); n + n would wrap in a u8, so n is a number and takeU8(n) needs n as u8.
Only types the program names count: passing the local to an integer parameter of the JavaScript
API (s.slice(k)) or using it as an index (xs[k]) leaves it a number, which the optimizer
stores as an integer where that gives the same results.
Locals used together (x = y, x + y) get one type. A local used both ways stays a number, and
each use as T is an error that says why, with the fix: declare the type (let i: i64 = 0,
which makes / on it integer division) or convert (i as i64).
as
as converts with Rust semantics: floats truncate and saturate (3.9 as i64 is 3,
NaN as i64 is 0), integers wrap (300 as u8 is 44, -1 as u8 is 255). So
x as i32 (saturates) and x | 0 (wraps modulo 2^32, as in JS) are different operations.
To any other type, as is TypeScript's type assertion and converts nothing: the expression is
typed as if annotated with that type, so a literal takes its shape
([["a", 1]] as [string, u8][], [] as string[]), and it must be a value of that type.
Checked assertions
An assertion that narrows is checked like x!: TypeScript trusts it, Velt panics with
type assertion failed: … when the value is not a T. Such assertions are:
x as Twithx: T | null(or| undefined);u as AwithAa member of unionu;u as A | B(also| undefined) with some ofu's members;u as A | Bof au: A | B | C | undefinedpanics on a nothing asu!does.
In generic code, x as T of an x: T | undefined is checked only where T has no undefined
of its own: at T = U | undefined every value of x is a T and passes as it is. At
T = U | null an undefined is no T value (Velt's U | null cannot hold one), so it
stops with a TypeError instead of reading as null: an assertion never changes the value.
u as A with A a member of u is checked in generic code too (mod as Mod<T> with
mod: T | Mod<T>), and so is a union of some of u's members that are not themselves type
parameters (r as Ok<T> | Err with r: Ok<T> | Err | Pend). Where a member is a type
parameter, the check follows the instantiation: at T = unknown (or any) every value is a
T, so x as T of an x: T | string holding a string passes, and mod as Mod<T> of a T
converts that unknown as u as Mod<unknown> would.
Tuple assertions
A tuple asserts element by element (t as [object, string] with t: [any, string | symbol]),
each element converted or checked as it would be alone. The result is a new tuple of those
elements, so a write into one of the two tuples does not show in the other, and t2 === t is
false (an element that is an object is the same object in both).
Field reads of an assertion
A field read of such an assertion, (u as A).f with u a variable or a field path (outside
generic code) whose union has as a member the object type A, which has the field f, reads as
JavaScript does instead of checking the assertion. An A gives its field, and a value of
another member gives its own property f: another object type's field f, the length of an
array or a string, and undefined for an array, a string, a number, a boolean or an object type
that has no such property. null or undefined stops with Node's
TypeError: Cannot read properties of null (reading 'f') ((u as A)?.f is undefined there).
A member whose property needs a lookup at run time (a class instance, which may be of a
subclass, or a member of a prototype such as an array's map) checks the assertion.
The read has the field's type. Where it is only tested, the value read is tested as it is: in a
condition (of if, a loop or ?:, and the operands of !, && and || there) its
truthiness, and in === undefined, == null, ??, ?. and typeof its value, undefined
included. So the common test of one member's field works as in Node:
type VNode = { type: string; props: number };
type Child = VNode | string | number | Child[];
function describe(c: Child): string {
if ((c as VNode).type) return "node " + (c as VNode).type;
if (Array.isArray(c)) return c.length + " children";
return "text";
}
console.log(describe([1, "a"]), describe({ type: "div", props: 1 }), describe("s"));
// 2 children node div textUsed as a value, the property read converts to the field's type, checked: an array's
undefined read as a string field stops with
TypeError: the value is not a string (it is undefined), where Node goes on with undefined.
An operand of <, >, <=, >=, -, *, /, % and ** is the exception: there a number
field's undefined is NaN, which gives Node's answer ((c as A).count > 0 is false,
(c as A).count - 1 is NaN). A method call (u as A).m() and a write (u as A).f = v check
the assertion (a call of what another member holds would be a TypeError in Node
too, unless it has a method of that name), and so does a member whose property needs a lookup
at run time, also under typeof: typeof (c as A).at of a string stops with
type assertion failed (Node answers "function").
Precedence
As in TypeScript, as and satisfies take a whole type: x as A | B is a union and
x as A & B an intersection (a leading | may start the type on the next line), so a bitwise
operator after a cast needs parentheses: (x as i32) | y. A number literal after | in a cast
type (x as i32 | 1) is an error that shows both spellings, (x as i32) | 1 and
x as (i32 | 1).
Strings
A string is an immutable value, like in JS: assign it, pass it, return it, store it, capture
it, take it out of a field, an array element, a for...of element or Map.get. The source stays
usable and no copy method is needed.
+concatenates two strings;s += xappends to a variable or field in place whensholds the only reference to its text, growing it geometrically, so building a string in a loop costs time linear in its length.s = s + xands = `${s}${x}`append the same way. Other copies ofsnever change.+with a string on one side and a number or boolean (or one of those ornull) on the other concatenates, as in JS:"Total: " + 5is"Total: 5","a" + trueis"atrue"ands += nappends. The number is written asString(n)andconsole.logwrite it (1.5,1e+21,NaN,Infinity,0for-0). An array or a tuple is written asjoin()writes it ("" + [1, 2]is"1,2"), a class instance by itsvalueOf()when that returns a primitive (+asks for it beforetoString(), as in JS; aDateby itstoString()), else by itstoString(), and a value whose type is a type parameter asString(x)writes the type it is instantiated with (ananyand a class instance with+'s order:valueOf()first), also inside another type ("v=" + t[k]withk: K extends keyof T). Any other value next to a string is written asString(x)writes it:"" + sof astring | string[] | nullis"k,l","x"or"null", an object without atoString()is[object Object], a map[object Map]. A function value there is a compile error.String(x)converts any value that can be printed to its text, exactly as`${x}`writes it (String(1.5)is"1.5",String(null)is"null",String([1, 2])is"1,2", also when the array is held by a union:String(x)of astring | number[]or aT | string).Number(x)converts a string (as JS: the whole trimmed string,""is0, elseNaN), a number or a boolean (1/0);Boolean(x)isx's truthiness. They are called as functions, not as values (xs.map(String)is not supported yet: writexs.map((x) => String(x))).- A template literal writes
${x}as JS'sString(x)does (Lexical structure): an array's elements joined with,(${[1, 2]}is1,2, nested arrays the same way,nullelements as empty text, a class instance through itstoString(), another object as[object Object], a map as[object Map], a set as[object Set]), and a class instance through itstoString(), else as[object Object]. An array whose elements JS writes with a method Velt cannot call there (a struct'stoString(), anError, aRegExp) is a compile error in a template literal, injoinand intoString(): write`${xs.map((x) => x.toString()).join(",")}`. - A string is a sequence of UTF-16 code units, as in JavaScript:
s.lengthcounts them, and every position (slice,indexOf,charCodeAt,padStart, regex offsets,s[i]) is a code-unit index. A character outside the Basic Multilingual Plane, such as an emoji, is two units (a surrogate pair):"Zoë".lengthis 3 and"😀".lengthis 2. A position may fall between the two halves of a pair; slicing there keeps the half as a lone surrogate ("😀".slice(0, 1)is"\uD83D"), and gluing the halves back together gives the pair again. Output writes a lone surrogate as U+FFFD. The byte size of a string in UTF-8 isBuffer.byteLength(s). s[i]iss.charAt(i): the code unit atias a one-unit string, or""past the end (JS:undefined).for (const c of s)and[...s]iterate the characters (code points: a pair is one element), as JS's string iterator does;s.split("")gives code units.- Methods:
slice substring indexOf lastIndexOf includes startsWith endsWith split trim trimStart trimEnd toUpperCase toLowerCase replace replaceAll repeat padStart padEnd charAt at charCodeAt, plusString.fromCharCode,parseInt,parseFloat,String(x)andNumber(s)(prelude). <,>andsort()without a comparator compare by code units, as JS ("~" < "😀"isfalse);==compares content.- Cost model: strings of up to 23 bytes (22 when they are not ASCII) are stored inline (no heap
allocation); longer ones live in a reference-counted immutable buffer. A copy is 24 bytes
plus, for a heap string, one count increment, and the compiler moves instead of copying at a
last use.
s.clone()compiles and is just a copy. Text is stored as UTF-8 (files, sockets and HTTP bodies need no conversion), with the code-unit count kept in the value:lengthis a load, and indexing ASCII text reads a byte. Indexing other text translates the position: a step from the last position of the same string, so a sequential loop over one or two strings at a time stays linear (each thread remembers its last two long non-ASCII strings), or a lookup in a table built for long strings plus a scan of at most 63 units (random access to long non-ASCII text is several times slower than in JS engines; in a long non-ASCII literal, which has no table, it scans from the closer end). - A
sliceorsubstringof a heap string (and a piece fromtrimorsplit) that is at least a quarter of its buffer shares the buffer instead of copying (as JS engines' sliced strings do), so a parser that consumes its input withrest = rest.slice(n)runs in linear time. A smaller piece is copied, so a short slice never keeps a much larger string alive: live slices hold at most four times their own size. - A string holds less than 2 GiB of text (more than JS engines allow). Making a longer one stops
the program with
string too long(repeatpanics with JS'sRangeErrormessage instead).
function label(name: string, count: i64): string {
let s = name; // a copy: `name` stays usable
s += ":";
return `${s} ${count} (${name.length} units, ${Buffer.byteLength(name)} bytes)`;
}
console.log(label("tea", 3), "a,b".split(","), " x ".trim().padStart(3, "*"));
// tea: 3 (3 units, 3 bytes) [ 'a', 'b' ] **x
console.log(label("Zoë", 1), label("😀", 2));
// Zoë: 1 (3 units, 4 bytes) 😀: 2 (2 units, 4 bytes)Symbols
Symbol(description?) makes a new symbol, a value different from every other one and
compared by identity; Symbol.for(key) returns the registry's symbol for key (the same one on
every call), and Symbol.keyFor(s) its key. A symbol has a description (null where Node
says undefined) and toString() (Symbol(description)); typeof s is "symbol", and
console.log shows it as Node does. Symbols are Map keys and Set members. The well-known
symbols are values too (Symbol.iterator, Symbol.asyncIterator, Symbol.toStringTag, …).
Converting a symbol to a string implicitly (${s}, "k" + s, also of a string | symbol) is an
error, as JavaScript throws a TypeError there; String(s) is allowed. Where the compiler
cannot see the symbol, Velt panics with that TypeError (exit code 101) instead: joining an
array or tuple that holds one ([s].join(), String([s]), ${[s]}, ${pair} of a
[string, symbol], also of a (string | symbol)[] that holds one), and a template literal part
whose type is a type parameter that is symbol or a union holding a symbol (`v=${x}` with
x: T, called with a symbol; the union's other members are written as usual). Unlike Node's
TypeError, this panic cannot be caught with try/catch. The API is Symbol.
A symbol a module constant holds names a member: [KEY]: T in an interface, an object type or
a class, { [KEY]: v } in an object literal, o[KEY] and KEY in o. The constant is
initialized with Symbol("...") (the description a string literal or a string constant) or
Symbol.for("...") (TypeScript's unique symbol;
const KEY: unique symbol = Symbol("k") declares the same), or with another such constant, and
may be imported. Velt objects have fixed fields, so the compiler resolves these members while
compiling: reading o[KEY] costs what reading a named field does. A symbol key known only at
run time (a symbol made in a function) is an error: use a Map. In an object literal, a string
key known only at run time ({ [o.tag]: v }, { [name]: v } with a local name) makes the
object a Record<string, V>, as TypeScript gives it a string index signature. A module constant
holding a string literal names that field the same way (const NAME = "name", [NAME]: T).
In a declaration (an interface, an object type or a class), a string or number literal in
brackets is the plain name (["k"]: number is k: number; [1]: string and 1: string name
the member "1", [-1]: string the member "-1", 1e3: number the member "1000"), also
after a modifier (readonly 7 = 4), and so in a destructuring pattern (const { 1.5: a, [-1]: b } = o). [-0] names the member "0", as String(-0) is in JavaScript (TypeScript's checker
reads it as "-0"). Such a name is for fields: a method or accessor named by a literal
(["a-b"]() {}, get "c d"(), get 1(), 1e3() {}) is not supported yet. A symbol made in
place ([Symbol("x")]: v, also in an object literal) is an error that asks for a module
constant. An enum member names the member its value spells, as in TypeScript:
[ReactiveFlags.SKIP]?: boolean with SKIP = "__v_skip" is the member __v_skip, and a
numeric enum's member is its number ([Slot.First] is "0"). It works in declarations, in
object literals ({ [ReactiveFlags.SKIP]: true }), in o[ReactiveFlags.SKIP] (also as an
assignment target) and in ReactiveFlags.SKIP in o, also with an imported enum. In a
destructuring pattern, a computed key is a string or number literal
or a name ({ [KEY]: v } = o); a path or another expression ({ [t.tag]: v } = o) is an error:
store the key in a variable first (const k = t.tag; const { [k]: v } = o).
A class or interface may declare a method, getter or setter with such a key ([KEY]() {},
get [KEY]()) or a well-known symbol key (get [Symbol.toStringTag]()); o[KEY], o[KEY](),
o[KEY] = v and KEY in o find it. A quoted key is an ordinary string key, also one that reads
like a symbol ("[x]", "[Symbol(k)]"); only names starting with "[Symbol. or with the
noncharacter U+FFFF are reserved (Velt names symbol-keyed members with them). console.log
shows a symbol-keyed member after the others as [Symbol(description)]: v, and
JSON.stringify and Object.keys leave it out, as in Node; JSON.parse never fills one,
whatever keys the text has (a required symbol-keyed field makes the decode fail).
A literal asserted to a type may carry a symbol-keyed member the type doesn't declare
({ type, props, [VNODE]: true } as VNode): TypeScript allows it and the object keeps the
member, so Velt adds the optional field [VNODE]?: boolean to VNode (an interface or an
object type alias of the program; the field's type is the literal value's, any for another
value or for literals of different types). Objects built without it don't have it, as in Node:
console.log shows it only where it is set, spreads copy it, VNODE in v tests it, and it is
read and written through any ((v as any)[VNODE] === true, (v as any)[VNODE] = true on an
object of a type that has the field). The key is resolved in the module of the literal, so the
type may be declared where VNODE is not imported. Reading such a key through any from a
value without fields (a string, number, boolean, array or function) gives undefined, as in
Node.
const BRAND: unique symbol = Symbol("brand");
type Tagged = { tag: string };
function branded(tag: string): Tagged {
return { tag, [BRAND]: true } as Tagged;
}
const a = branded("div");
const b: Tagged = { tag: "p" };
console.log(a, b, BRAND in a, BRAND in b); // { tag: 'div', [Symbol(brand)]: true } { tag: 'p' } true false
console.log((b as any)[BRAND] === true, { ...a }); // false { tag: 'div', [Symbol(brand)]: true }KEY in o is true when o's type declares the member, false when it doesn't, and for an
optional member ([KEY]?: T in an object type or interface) whether it is present, tested at
run time (a class's optional field is always present, as TypeScript-compiled classes define
it). On a union it answers for the member o holds, and in an if it narrows the union to the
members that may have the key, as TypeScript does. Symbol.iterator in o and
Symbol.asyncIterator in o answer from the type's methods: arrays, Maps, Sets and generators
have Symbol.iterator, async generators Symbol.asyncIterator, and Symbol.asyncIterator in g()
on a generator is false. Another well-known symbol in a built-in object
(Symbol.toStringTag in m) is an error rather than a guess.
const VNODE: unique symbol = Symbol("vnode");
const MODEL = Symbol.for("app.model");
interface VNode {
type: string;
[VNODE]: true;
}
function isVNode(v: VNode | string): boolean {
return typeof v !== "string" && v[VNODE] === true;
}
type Cell = { value: number; [VNODE]?: boolean };
function describe(c: Cell | VNode): string {
if (VNODE in c) {
return "vnode or marked cell";
}
return `cell ${c.value}`;
}
const n: VNode = { type: "div", [VNODE]: true };
console.log(n, isVNode(n), VNODE in n); // { type: 'div', [Symbol(vnode)]: true } true true
console.log(JSON.stringify(n), MODEL === Symbol.for("app.model")); // {"type":"div"} true
const tokens = new Map<symbol, string>();
tokens.set(MODEL, "model");
console.log(tokens.get(Symbol.for("app.model")), typeof VNODE); // model symbol
console.log(describe({ value: 1 }), describe({ value: 2, [VNODE]: true })); // cell 1 vnode or marked cellA module constant initialized with Symbol(...) or Symbol.for("...") is one symbol for the
whole program, made while compiling (module constants are otherwise evaluated at each use), so
reading it costs no call; Symbol.for(k) at run time gives that same symbol for its key. A symbol Symbol(...) makes at run time lives
until the program ends. A symbol brand added with Object.defineProperty(o, KEY, …) is an
optional symbol-keyed field of o's type (Object built-ins). A record
holds symbol keys made at run time too (see Record<K, V> in
Objects, arrays, tuples and maps). PropertyKey is
TypeScript's string | number | symbol; a proxy trap's key is a string | symbol.
SIMD vectors
A SIMD vector holds several numbers of one type, its lanes, and an operation on it works on
all of them with one CPU instruction: a u8x16 is sixteen u8, an f32x4 four f32. The
types are i8x16 u8x16 i16x8 u16x8 i32x4 u32x4 i64x2 u64x2 f32x4 f64x2, each 128 bits, which
every x86-64 and arm64 CPU has: the same source compiles everywhere, with no CPU flag, in debug
and release builds. On WebAssembly they are v128 values: WebAssembly SIMD is part of Velt's
WebAssembly baseline, and a program that uses vectors needs an engine with it (every current
browser and runtime). They are Velt's own, and not global: a module imports the ones it names
from velt/simd (import { u8x16 } from "velt/simd";), so a module that
does not is free to use these names for its own types, as TypeScript code may.
A vector is a value like a number: it is copied when assigned or passed, lives in a register,
and can be a field, an array element, T | null or a Map value. Its operations are methods
(simd), not operators, and it has no ==, truthiness, printed or JSON form:
comparing two vectors with eq, lt, … gives a mask (mask8x16 for a u8x16), one lane
per lane, set or not, which any(), all(), first(), count() and bitmask() turn into a
boolean or a number and select into a choice between two vectors.
import { u8x16 } from "velt/simd";
// The position of the first `b` in `d`, sixteen bytes at a time.
function indexOf(d: u8[], b: u8): i64 {
const needle = u8x16.splat(b);
let i: usize = 0;
while (u8x16.fits(d, i)) {
const found = u8x16.load(d, i).eq(needle);
if (found.any()) {
return (i + found.first()) as i64;
}
i += 16;
}
while (i < d.length) {
if (d[i] == b) {
return i as i64;
}
i++;
}
return -1;
}
const text: u8[] = Buffer.alloc(100);
text[70] = 33;
console.log(indexOf(text, 33)); // 70load and store panic with a RangeError unless the whole vector is inside the array, and
lane(i) unless i is a lane. fits(xs, i) is the test they make: in a loop that runs while
it holds, as above, nothing is left for them to check.
A loop over plain numbers is often compiled to the same instructions without any of this
(summing an array, adding two arrays); vectors are for the loops it cannot do that for, such as
one that stops early, or a sum of floats, whose order it must keep.
Equality and comparison
-
==and===(and!=and!==) differ only againstnullandundefined, as in JavaScript:x == nullandx == undefinedmatch either nothing,===only the one written (Null and undefined). Everywhere else they are the same operator: there is no coercion, and the operands' types must overlap, as in TypeScript (1 == "1"is a compile error; ani64compared with ani32needs a cast). An interface value compares with a value of a class or struct that implements it, and a base class value with a subclass value. -
Numbers, bools and strings compare by value. Objects (class instances, arrays, maps, structs, object literals, interface and function values) compare by identity, like JS:
[1] == [1]isfalse, anda == bistruewhenbrefers to the same object asa.T | null, unions and tuples compare their parts that way. AT | nullcompares with aT(in either order) as if both wereT | null:nullequals no value. Likewise a union compares with one of its members (string | numberwithnumber): the two are equal when the union holds that member with an equal value, so the string"3"never equals the number3, as with===in JS. -
Every number is a number, though: in a union of several number types (
f64 | u8, which[bytes[0], bytes.length]has) two values are equal when their numbers are, whichever members hold them, so1.0equals1u8(in===,switch,MapandSetkeys,indexOf,includesanddeepEqual), andx === 1tests every member that can hold a 1. A number literal type is a number member too: in1 | 2 | u8, the1equals1u8. The comparison is exact: ani64above 2^53 equals nof64it only rounds to. A literal goes to a member that can hold it (256asf64 | u8is thef64); when none holds it exactly, a float member takes it rounded, as a plain float type does (x === 0.1withx: f32 | u8tests thef32nearest 0.1).includesfinds aNaNin a union array, as in anumber[];indexOfdoesn't, as in JS.tsconst bytes = new Uint8Array([7]); const pair = [bytes[0], bytes.length]; // (f64 | u8)[] console.log(deepEqual(pair, [7, 1]), pair.includes(1)); // true true -
An interface value compares the object behind it: two
Shapevalues of one class instance are equal. A function value is equal to its copies, and a named function to itself; each evaluation of an arrow or function expression is a new function, as in JS, also when it captures nothing (soemitter.off(h)finds thehgiven toemitter.on(h), and two arrows made by one loop differ).indexOf,includes,Setelements andMapkeys agree with==(includesand keys also findNaN, as in JS), except thatMapandSetkeys of a struct type compare by content. Comparing costs only the programs that do it: there, an arrow without captures gets an empty environment of its own when it is created, and a struct converted to an interface value that is compared is counted, so the interface value refers to it rather than to a copy.tsconst h = () => console.log("h"); const handlers = [h]; const fresh: (() => void)[] = []; for (let i = 0; i < 2; i++) { fresh.push(() => console.log("h")); } console.log(handlers.indexOf(h), h === h, fresh[0] === fresh[1]); // 0 true false -
Content comparison:
deepEqual(a, b)(prelude) compares arrays, structs and object literals by their contents, recursively; maps and records by their keys and values, in any key order; other class instances by identity. A value that holds itself (a ring of object values) compares without looping: the same cycle on both sides is equal.assertEquses it. -
<,<=,>,>=work on numbers and strings, and on a class or struct implementingComparable(Date,DateTime) or a genericT extends Comparable<T>:a < bisa.compareTo(b) < 0(Comparable).
Null and undefined
null and undefined are two values, as in JavaScript. A value that may be absent has type
T | null, T | undefined or T | null | undefined (A | B | undefined for a union), stored
without an extra allocation where possible; T | null and T | undefined take the same space,
and T | null | undefined holds a T | null with one more flag. undefined behaves as in Node:
function find(xs: string[], s: string): string | undefined {
return xs.includes(s) ? s : undefined;
}
const a = find(["x"], "y");
console.log(a, typeof a, a === undefined, a == null); // undefined undefined true true
console.log(a ?? "none", `${a}`, [a]); // none undefined [ undefined ]
console.log(JSON.stringify({ a, b: 1 }), JSON.stringify([a])); // {"b":1} [null]
const v: number | undefined = void console.log("ran"); // `void e` evaluates `e`: undefinedA T | null | undefined keeps the two apart: x === null and x === undefined each test
one, x == null both, and typeof, printing and JSON.stringify see which one it holds.
x !== undefined narrows it to T | null, x !== null to T | undefined, and both tests (or
x != null, or truthiness) narrow it to T. Compared with a T | null or a T | undefined,
=== tells the nothings apart and == does not, as in Node. A conditional with both a null
and an undefined arm (i === 1 ? null : i === 2 ? undefined : i) is a T | null | undefined.
A generic union whose type argument brings a nothing (T | string | undefined at
T = number | null) holds it like the union written out (number | string | null | undefined):
== null, ===, ?? and narrowing see it, inside the generic function too. At T = unknown
(or any) such a union (T | string, T | Mod<T>) holds any value: one that is no other
member's is its unknown member's (null, 0, an object literal of no member's shape: other
fields, values its fields cannot hold, judged for literals and for variables and field paths by
their types, or a discriminant no member has), and null tests (also a == b of a null and an
undefined), truthiness (!x, if (x), &&, ||), ??, ??=, ?., typeof (also
typeof x === "number" and switch (typeof x)), printing and JSON.stringify ask that value
itself, as they do a plain unknown. Where typeof x === "string" holds, x reads as a
string also when generic code put the string in the T member (s<T>(v) with v: T passed
to s<T>(x: T | string)). A variable or field of a type parameter passed for such a
parameter (i<T>(v) with i<T>(x: T | Foo), also T | null, T | undefined and
T | Foo | null) goes to the member its value belongs to at T = unknown (or any), as an
unknown passed where the type is written out does, so x instanceof Foo, x === null,
x === undefined, x === true and x === "a" see a Foo, a nothing, a boolean or a string
that generic code passed; at other instantiations the value passes as it is. A value
passed another way (a call's result passed as it is: i<T>(f(v))) stays in the T member. a == b of two values of a type parameter's type (also
T | null | undefined) compares with JavaScript's coercions at T = unknown or any
(1 == "1"), as == on any does. An any or unknown converted to it (const n: number | null | undefined = v, v as T | null | undefined) gives null for a null and undefined for an undefined. Converted to a
T | null or a T | undefined, it gives the nothing that type has, and stops with a
TypeError for the other one (const n: number | null = v with a v holding undefined), as
for any other value the type cannot hold: Node would keep the undefined. undefined
alone is a type too (its only value is undefined), and an
undefined with no type to take has it (const u = undefined, [1, undefined] is a
(number | undefined)[]), and a conditional of null and undefined with no type to take is
a null | undefined (const d = c ? null : undefined). A let x: T | undefined; without an initializer starts as
undefined, and so do let x: any; and let x: unknown;. null alone is a type as well, as in TypeScript's discriminated unions where a
field is always null in one member ({ st: "idle"; value: null } | { st: "ready"; value: number }).
function show(x: string | null | undefined): string {
if (x === undefined) return "undefined";
if (x === null) return "null";
return x.toUpperCase();
}
const xs: (string | null | undefined)[] = ["a", null, undefined];
console.log(xs.map(show), xs.map((x) => typeof x)); // [ 'A', 'null', 'undefined' ] [ 'string', 'object', 'undefined' ]
console.log(JSON.stringify({ a: xs[1], b: xs[2] }), xs[2] ?? "d"); // {"a":null} d
let later: number | undefined;
console.log(later, [1, undefined]); // undefined [ 1, undefined ]x == null and x == undefined match either nothing; x === undefined tests a
T | undefined for its nothing, and x === null on a T | undefined is false (!==: true)
and narrows nothing, as in Node. tsc accepts it; Velt warns (this `=== null` is always false: `string | undefined` is never `null` ), since it is usually a test of an optional parameter or
field written for null, which is undefined when left out: write x == null to test for
either nothing, or x === undefined. switch compares like ===. typeof x === "undefined"
narrows, ?. on a value that may be undefined gives undefined (a T | null result
becomes T | null | undefined), and JSON.stringify leaves out a field
holding undefined and writes an array element as null. A destructuring default applies where
the value is undefined (an undefined element or field, an absent optional field, a too short
array), never to a null a field or element is declared to hold: const { a = 1 } = o keeps
the null of a field a: T | null, and of an optional a?: T | null that holds null. sort(cmp)
and toSorted(cmp) on a (T | undefined)[] never pass an undefined element to cmp and put
those elements last. return undefined; in a void function is return;, and return void e; (or an arrow body (): void => void e) is e; then return;. T | void is
T | undefined (a void function's value is undefined), so a callback typed
(e: Error) => boolean | void may return a boolean or nothing. A function whose result type
allows undefined returns it from a bare return; and when it reaches its end; one whose result
type is inferred, that returns values and also return; or can reach its end, returns
T | undefined, as in TypeScript. null where T | undefined is expected, and undefined where
T | null is, are errors (TS2322), also for a T | undefined value where a T | null is
expected and the reverse.
A parameter's default applies where the argument is undefined, as in JavaScript:
f(undefined) and f(v) with a v: number | undefined that holds undefined give
function f(x = 5) its 5. So does a T | null | undefined value for a T | null parameter
with a default (before: Node | null = null given a before?: Node | null): undefined takes
the default, a null stays null. A parameter whose type allows undefined may have a default
too (function f(x: number | undefined = 5), or status = 302 in a method whose interface
declares status?: number); it is a number in the body.
function label(n: number = 1, unit: string = "px"): string {
return `${n}${unit}`;
}
function inc(n: number | undefined = 7): number {
return n + 1;
}
const found = [1, 2].find((x) => x > 5); // number | undefined
console.log(label(found), label(undefined, "em"), inc(), inc(undefined), inc(1)); // 1px 1em 8 8 2Not supported yet, each with a precise error:
null | undefined alone as a written type, JSON.stringify
of a value that may itself be undefined and JSON.parse into a type holding undefined;
x === undefined and typeof x === "undefined" on a T | null (its null may be JavaScript's
undefined, see below; x == null tests for either) and case null / case undefined with
the nothing the type lacks; as between T | undefined and T | null; a parameter default
other than undefined on a parameter whose type allows both nothings (T | null | undefined);
and a type argument whose nothing differs from one the generic keeps
of it, directly or through what it calls (undefined, or a T | undefined, for a generic
function holding a T | null), which includes a generic
alias applied that way (type Nullable<T> = T | null as Nullable<number | undefined>). The
other way round is supported: a generic holding a U | undefined at U = T | null holds a
T | null | undefined. A Map or Record value type may allow either nothing or both
(Map<string, number | null | undefined>, Record<string, V | undefined>): an entry holding
null or undefined is present (has, size, Object.keys, iteration and console.log
list it) and reads back as what it holds, a missing key reads undefined, and
JSON.stringify leaves out an entry holding undefined and writes null, as Node does.
JSON.parse into a type holding a T | null | undefined (as Record<string, V | null | undefined>) reads each value as its T | null, since JSON has no undefined; into a
T | undefined without null it is not supported yet.
Also not supported yet, although TypeScript accepts them:
- a type argument that allows
undefinedfor generic code that sorts an array of its type parameter (xs.sort(cmp)insidefunction f<T>(xs: T[], …)): anundefinedelement would reach the comparator; JSON.stringifyof a value holding a class whosetoJSON()may returnundefined(its own or a subclass's).
The standard library
Where TypeScript's lib.d.ts says undefined, so does Velt's standard library, as in Node: a
missing key of a Map (m.get("z")) or of an open Record (r["z"]), find / findLast
with no match, pop / shift of an empty array, at out of range (arrays, strings, typed
arrays), the value of a finished iterator's result, WeakMap.get, WeakRef.deref of a freed
target, Symbol.keyFor and description of a symbol without one, and an unset
process.env.X. Each gives a T | undefined (a T | null | undefined when T holds null:
m.get("a") of a Map<string, number | null> tells a stored null from a missing key).
Velt's own collections follow the same rule (Deque, PriorityQueue, SortedMap,
BTreeMap). APIs whose
lib.d.ts result is null keep it: RegExp.exec, String.match, Headers.get,
URLSearchParams.get.
const m = new Map<string, number | null>([["a", null]]);
console.log(m.get("a"), m.get("z"), m.get("z") === undefined); // null undefined true
const xs = [1, 2, 3];
const none: number[] = [];
console.log(xs.find((x) => x > 5), none.pop(), xs.at(9)); // undefined undefined undefinedIndexing past the end of an array (xs[9]) still panics: TypeScript types it as T, not
T | undefined. The helpers isNull, unwrap, unwrapOr and map work on these results as on
a T | null (prelude).
Optional values
Optional fields and parameters (a?: T) hold undefined when left out, as in TypeScript, and
so do the standard library's lookups (see above). ??, ?., ! and x != null treat both
nothings alike:
x ?? d(default;x ?? nullof aT | null | undefinedis aT | null),x?.f/x?.m()(optional access; the result may beundefined),if (x != null) { … }and early exits narrowxtoT(a local or a field path of one, see below), and so does a truthy chain that goes on afterx?.(if (x?.m.has(k)) x.m.get(k),if (x?.ok()));switchsupportscase nullandcase undefined. The type ofx ?? disx's non-null type whendconverts to it, elsed's type whenx's non-null type converts to that, else their union, as in TypeScript: withn: number | null,n ?? "none"is anumber | string. Where anany(orunknown) is expected and one side is anany, it is anany(see||in Variables).x ??= dassignsdwhenxisnullorundefinedand narrowsx(likewisex ||= dandx &&= d). Its value isxafterwards, which is not null whendis not, also for a module-level variable ((handlers ??= []).push(h)). The target may not call a function yet (m[key()] ??= v): store the key in a variable first.x!isxknown to be neithernullnorundefined, checked at run time (the non-null assertion).a?: Treads asT | undefined, anda?: T | nullasT | null | undefined. An optional parameterb?: Tisb: T | undefined = undefined(Functions): callers may leave it out or passundefined(or a value that may beundefined), but notnull(TS2322), and it cannot also have a default.- An optional field of a class without an initializer is an own property holding
undefined, as in Node with ES2022 class fields:console.logshowsa: undefined,JSON.stringifyleaves it out, and"a" in oistrue. It can be set tonullonly when declareda?: T | null; it then printsnullandJSON.stringifywritesnull. A spread always copies it, set or not (in a generic function too): over an earlier value of a field that can holdundefineditsundefinedoverrides, as in Node, and over a required field ({ ...u, ...c }withu.n: number) an unset one stops the program with theTypeErrorof absent keys. - An optional field of an object type or of an interface with only fields keeps an absent key
apart from a key holding
undefined, as Node does (absent keys). - A spread of a conditional of object literals,
{ id, ...(c ? { boundaryId: id } : {}) }, adds the keys of the branch taken, as in Node (absent keys). - An object literal may leave out an optional field (
{ port: i64; host?: string }accepts{ port: 80 }). A field of an object type declareda: T | nullmay be left out too, and is thennull. - In an object type,
?is part of the type, as in TypeScript:{ a?: string }and{ a: string | undefined }read alike but are different types (the first may be absent), with no implicit conversion between them; copy with{ ...x }. Difference from TypeScript: TypeScript also accepts a{ a: F | undefined }where a{ a?: F }is expected; in Velt the two are different types (the second keeps a presence flag), so copy with{ ...x }there. JSON.parse<T>: an absent key leaves an optional field absent (undefined). An explicitnullfor ana?: Tfield, whose type has nonull, leaves it absent too (a Velt rule: the type cannot hold thenull); for ana?: T | nullfield it is a presentnull. The TypeScript forms that take the type from the context,JSON.parse(text) as Tandconst t: T = JSON.parse(text), reject thatnullinstead (expected string at $.a), as they reject a value of another type. A field declareda: T | null(not optional) may be missing, and is thennull, as for an explicitnull. AJsonValuealways tells them apart:v.has("a")vsv.get("a")?.isNull().x?.a.bshort-circuits the rest of the chain like TypeScript (undefinedwhenxisnullorundefined;.bis never evaluated). This holds for aT | nullxtoo, as in JavaScript: withu: User | null,u?.nameis astring | undefined, and a rest of typeT | nullgivesT | null | undefined. Parentheses end a chain:(x?.a).bneedsx?.ato be neithernullnorundefined.- Null checks narrow locals and field paths of locals (narrowing nullable values), and a variable a closure assigns until the next assignment (variables a closure assigns), and module variables and record entries (module variables).
x == null,x != null,x === undefinedandx ?? dare allowed on a value whose type has neithernullnorundefined, as in TypeScript (a defensive check): the test isfalse(!=:true) anddis not evaluated. On an element read of an array, a string or a typed array (xs[i] ?? d,xs[i] == null) the index is checked instead, so past the end the result isd(true), as in JavaScript, where reading past the end givesundefined.const x = node.left/const row = grid[i]refers to the same object as the field or element (objects are references, Memory model); when the rest of the block replacesnode.left,xkeeps referring to the old object, as in JS.
class User {
name: string = "ann";
nickname?: string; // starts as undefined
}
function greet(name: string, greeting?: string): string {
return `${greeting ?? "Hello"}, ${name}`; // greet("ann") works: greeting is undefined
}
function first(xs: i64[]): i64 | null {
return xs.length > 0 ? xs[0] : null;
}
function doubledFirst(xs: i64[]): i64 | null {
const f = first(xs);
if (f == null) {
return null; // early exit: f is i64 below
}
return f * 2;
}
const u: User | null = new User();
console.log(u?.name, u?.nickname ?? "(none)", doubledFirst([]) ?? -1, greet("bo"));
const f = first([4, 5]);
if (f != null) {
console.log(f + 1); // f is i64 here
}The non-null assertion x!
x! is x known to be neither null nor undefined (TS's non-null assertion). TypeScript
trusts it; Velt checks it: a nothing panics with non-null assertion failed.
A write into a tuple behind ! or as (one that keeps the tuple's type, or a union's tuple
member; an element-wise tuple assertion makes a new tuple, see as)
changes the tuple in place, wherever it is held:
- a variable or a field;
- an element (
h.t![0] = v,xs[i++]![1] = "q",g[r][c]![0] += 1), or a tuple inside such a tuple (g[0]![0]![1] = 9); - a field read through an interface (
iface.opt![0] = 3,is[q++].t![0]++); - a union member (
(u as [number, string])[0] = 9); - the object a conditional or a call gives (
(c ? a : b).t![0] = 5,ps.find(f)!.t![1] = "x").
Every index is evaluated once, before the right side. If the right side (or another task
during its await) sets the field to null, the program panics with the tuple this assignment writes into was set to null while its right-hand side ran, where Node writes into
the tuple the field held before; a right side that changes a union to another member panics
with type assertion failed.
Absent keys
An optional field of an object type or of an interface with only fields keeps an absent key
apart from a key holding undefined, as Node does:
- an absent field is not printed by
console.log, and"a" in oisfalse; { a: undefined }is allowed, prints{ a: undefined }, and"a" in oistrue;JSON.stringifyleaves out both, and writes a presentnull(of ana?: T | null).
A spread ({ ...a, ...b }) copies a field b has over an earlier value, also one holding
undefined or null, and keeps the earlier value where b lacks the field
(update(u, { deletedAt: undefined }) below clears deletedAt, as in Node).
Where the target field is required and cannot hold undefined (a Partial<U> spread back into
a U), a source that lacks the field keeps the earlier value, and one that holds undefined
stops the program with a TypeError (a spread copies `undefined` into a field of type `string` ): Node would copy the undefined, which the field's type can't hold. Without a
target type the field has the earlier part's type, as TypeScript types it ({ ...u, ...p }
with u.n: number and p.n?: number has n: number), so the same holds there.
type User = { name: string; deletedAt?: string | null };
function update(u: User, patch: Partial<User>): User {
return { ...u, ...patch };
}
const u: User = { name: "ann", deletedAt: "2026-01-01" };
console.log(JSON.stringify(update(u, {}))); // {"name":"ann","deletedAt":"2026-01-01"}
console.log(JSON.stringify(update(u, { deletedAt: null }))); // {"name":"ann","deletedAt":null}
console.log(update(u, { deletedAt: undefined })); // { name: 'ann', deletedAt: undefined }
const v: User = { name: "bo" };
console.log(JSON.stringify(v), "deletedAt" in v); // {"name":"bo"} false
console.log(update(v, { deletedAt: null })); // { name: 'bo', deletedAt: null }A spread of a conditional of object literals, { id, ...(c ? { boundaryId: id } : {}) }, adds
the keys of the branch taken, as in Node: the conditional is an object type in which a field
only some branches write is optional (TypeScript's { boundaryId: number } | {}, spread as
{ boundaryId?: number }), so the key is absent when the other branch is taken. Each branch is
checked under the narrowing of the condition (r ? { redirect: { ...r } } : {}); a key the
branches write with different types has their union (c ? { k: 1 } : { k: "s" } spreads
k: number | string; c ? { k: [1] } : { k: null } spreads k: number[] | null), and so does
a key an earlier property or spread gives another type ({ k: false, ...(c ? { k: 1 } : {}) }
has k: number | boolean). The result is one object type, not TypeScript's union of object
types, so "k" in o does not narrow o.k (a present key may hold undefined): test o.k
itself. A getter of a branch is called where the spread reads it, in property order, after the
values written before it. A getter using this, or one that a value other than a literal
follows, is not supported there yet (an error), and a setter keeps the literal's own rules.
Narrowing nullable values
Narrowing applies to locals and to field paths of locals (this.x, node.left), like
TypeScript (module variables: see below); assigning a non-null value narrows too (also in the declaration:
let x: string | null = "a").
After an if or a switch, what holds is what each way out ends with: the facts of the
condition a branch started from unless it reassigned the variable, and what its own statements
established:
if (x == null) { x = d; }narrowsxafter it;if (x == null) return; else x = null;does not;- a
casepasses on what holds at itsbreak; - after a
whileloop withoutbreakits condition is false:while (y === null) { y = next(); }.
A narrowed field is re-checked where its payload is read (see
module variables for where that is), so a call that set
it to null in between stops the program with
TypeError: `node.left` was checked, but it was set to null before this read (undefined for
an optional field) instead of reading null. A field path of a local is read as its payload
wherever no type is expected (const { n } = o.f, "n" in o.f, const c = o.f), and as it is
in a log argument, a template, a + with a string and a test. Node's message for the same
mistake is different (Cannot read properties of null (reading 'x')), and Node throws a
TypeError a program can catch, where Velt stops. An optional member of function type can be called once a check
narrowed it (if (o.onError) o.onError(e), if (def?.transform) def.transform(v)).
Inside a closure, a variable narrowed where the closure is created stays narrowed when the function assigns it nowhere after that (nor in a loop around the closure), as in TypeScript: the closure may run after such an assignment. It stays narrowed in the whole closure body, also after a branch or loop where the closure first names it.
Variables a closure assigns
A null check (!= null, !== undefined, truthiness) of a variable that a closure assigns
narrows it in the function that makes the check until the next assignment there, as in
TypeScript (if (cleanupFn) cleanupFn();). A call in between may run the closure, so each
such read checks the value again and stops with a TypeError if the closure set it to null
(JavaScript throws one where it calls or reads through the null). The closures that capture
the variable do not see the narrowing.
Other tests (typeof, instanceof, union members) do not narrow such a variable, where
TypeScript keeps the narrowing: the variable may no longer hold what was checked. Test a
const copy instead, which nothing can reassign; the error at such a use says so:
class Conn {
send(msg: string): string { return `sent ${msg}`; }
}
let conn: Conn | string = new Conn();
const close = () => { conn = "closed"; };
const c = conn; // `if (conn instanceof Conn) { conn.send(…) }` is an error
if (c instanceof Conn) {
close();
console.log(c.send("bye")); // sent bye
}Module variables and record entries
A module-level let (or a module const kept as
module state), also an imported one, is narrowed by a
null check in the function that makes it, as in TypeScript (if (handler) handler();,
if (!ctx) return; ctx.onMounted(fn), if (app?.provides.has(k)) return app.provides.get(k)),
and so are its field paths (if (state.next) state.next.name, typeof state.v === "string").
Any call may run code that assigns it (a function of its module, a callback, a getter, another
task across an await, a generator's caller across a yield), so each narrowed read checks the
value again, like a variable a closure assigns, and stops with a TypeError if it became null
(`ctx` was checked, but it was set to null before this read).
The variable (or field path) is read as its payload only where that is needed: as a receiver
(ctx.m(), ctx.n = 1), a callee, an operand of a numeric operator or a comparison (n + 1,
n > 2, -n, s + 1, s + t with both narrowed), a spread (...xs), where the expected type
does not take the value as it is (use(ctx) with c: Ctx, return ctx from a function
returning Ctx, len(st.v) with v: string after typeof st.v === "string") or what a
destructuring takes apart (const { name } = ctx). Elsewhere it is read as it is:
console.log(ctx), JSON.stringify(ctx), an element of an untyped array (unlike TypeScript,
[x, y] of two narrowed Box | null module variables, module fields or r[k] is a
(Box | null)[], and holds null where a call cleared one), a template, a + with a string
("a" + s, s + "!", s += x, also a branch of a ternary there: "a" + (c ? s : "z")),
===, and where the expected type takes it (a parameter of type Ctx | null | undefined, or
Base | null for a S1 | S2 | null of subclasses, or string | number for a field typeof
narrowed to a string). So is the operand of a test, ?. or ??, so
if (ctx) { reset(); ctx?.name } gives undefined, as in JavaScript.
- An untyped copy (
let c = ctx,const c = state.next,const s = st.vafter atypeoftest, and a copy of such a copy) holds what it read,nullor a value of another member included, and keeps the narrowing: its reads follow the same rule (console.log(c)printsnull,c.namestops with aTypeError), and assigning it back (state.next = c,ctx = c) narrows the field or variable again, checked the same way. - A write through a narrowed field path takes the object first, as JavaScript does:
state.next.items[0] = f()writes into the objectstate.nextheld beforef()ran, also whenf()setstate.nexttonull, and so dostate.next.ts[0][1] = f()(a tuple in an array),state.next.p.x = f()(a struct or object field) anda.items[0] = a.items[1] = f(). A tuple right behind the narrowed field is a value, not an object, sostate.t[0] = f()stops with theTypeErrorwhenf()clearedstate.t. - An assignment in the function drops the narrowing (and narrows to what it assigned), and so does a loop that assigns the variable. Closures don't see the narrowing of the function they are made in (TypeScript keeps it in one made after the variable's last assignment).
typeof,instanceofand union-member tests do not narrow a module variable itself, andinstanceofdoes not narrow its field paths: copy the value into aconstand test that.
r[k] on a Record<string, V> with a key that is a const narrows like r.name: after
if (!r[k]) r[k] = []; it is a V, and so is r[k] in for (const k in r). Assigning r or
delete r[k] drops the narrowing; a read is checked again where its payload is needed, as a
field's is, so a call that deleted the key gives undefined in console.log(r[k]).
let current: { name: string } | null = null;
function show(): void {
current = { name: "ann" };
if (current) console.log(current.name); // ann
}
function group(words: string[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const w of words) {
const k = w.slice(0, 1);
if (!out[k]) out[k] = [];
out[k].push(w);
}
return out;
}
show();
const g = group(["ab", "ac", "b"]);
for (const k in g) console.log(k, g[k].length); // a 2, then b 1Literal types
A string, number or bool literal is a type with that one value: "circle", 42, -1, 1.5,
5u8, true. type Dir = "up" | "down" is a union of literal types.
- A literal takes a literal type only where one is expected (an annotation, a parameter, a
field, a union with literal members); elsewhere it has its base type (
const s = "up"is astring). A literal-typed value converts implicitly to its base type (const s: string = d;). - Literal types work as type arguments too, negative numbers included:
f<"x" | null>(),g<-1>(5). As in TypeScript,a < -1stays a comparison: a literal after<starts type arguments only when>,|or,follows it. - A literal type is zero-sized; a union of literals is only its tag. Printing,
${}andJSON.stringifyshow the value;typeofgives the base type's tag. JSON.parsechecks a literal type against its value (expected "task" at $.kind).e as constis accepted and keeps the value as it is. An array literal of strings keeps their literal types and is read-only, like TypeScript'sreadonly ["a", "b"]:const NAMES = ["a", "b"] as constis an array of"a" | "b"(and["a"] as constof"a"), sofor (const n of NAMES) f(n)andf(NAMES[0])pass a"a" | "b"parameter, and areadonly string[]parameter or variable, anIterable<string>parameter and the arraynew Set<string>(…)copies from take it, or any array of a literal type (as a copy of strings, which the callee cannot tell apart, since it cannot write through the parameter). Other values need noreadonlyor literal-type annotation for it.
Union types
A | B | C of any types is a tagged union. Member order and nesting don't matter; T | null is
the nullable type; void cannot be a member.
- Widening is implicit: a member value (also a subclass of a class member, or a value
implementing an interface member) converts to the union, and a union converts to a wider
union. An array literal with no type from context whose elements have different types
is an array of their union, as in TypeScript (
[1, "a"]is a(number | string)[], and[a, d]of anumber | undefinedand astring | nullholds all four).nullandundefinedelements make it allow them ([1, null]is a(number | null)[]), a nested array literal keeps its own type ([[1], ["a"]]is a(number[] | string[])[]), and[...xs, true]addsbooleanto the spread arrays' element type. A literal takes the member it belongs to; add a suffix (5i32) when several number members match. - Narrowing by
typeof,instanceof, literals,inand assignment selects members (Narrowing). - Printing and template literals show the active member's value. A union with a member that
cannot be printed (a closure) prints once a test has narrowed it to members that can
(
typeof v !== "function").JSON.stringifyworks on unions;JSON.parsedecodes them when the JSON value tells the members apart (discriminated unions by their discriminant; seevelt/encoding/json). - A union of numbers, bools, strings and literals is copied; one holding an object refers to it like any other variable.
- A union alias may name itself behind an indirection: as an array's element, an index
signature's or a
Map's value, or in a function type, as JSON-like types do; its members may includenull,undefinedor both (type Child = Node | string | null | undefined | Child[]). A generic one (type Content<F> = string | F | Content<F>[]) is a union of its own at each type argument. Elsewhere (type L = number | { next: L }, a tuple) a value would contain itself, which is an error: give such a type a nominal member, such as an interface or a class.
type Json = string | number | boolean | null | readonly Json[] | { readonly [key: string]: Json };
type Tree = number | Tree[];
function depth(t: Tree): number {
if (typeof t === "number") {
return 0;
}
let max = 0;
for (const k of t) {
max = Math.max(max, depth(k));
}
return max + 1;
}
const v: Json = { a: [1, "x", { b: null }] };
console.log(JSON.stringify(v)); // {"a":[1,"x",{"b":null}]}
console.log(depth([1, [2, [3]]])); // 3Narrowing
Using a member's fields or methods needs that member, except x.toString(), which every member
has: it gives what String(x) does (a class's own toString(), a primitive's text), also as
x?.toString() on a union that may be null. These tests and assignments narrow a union:
typeof x === "string" | "number" | "boolean" | "symbol" | "object" | "function"(and!==): all number types are"number"; classes, structs, arrays, maps andnullare"object"; closures are"function". An impossible tag is an error. On a value that is not a union, a test it always passes leaves itneverwhere the test fails, when its type fixes the answer (numbers, strings, booleans, symbols, functions, classes, structs, arrays, maps); not forobject, which may hold a function, nor for interfaces with methods or enums. On a value whose type is a type parameter (alsoT | undefined),typeoftells the instantiation's value at run time, andx === undefined/x == nulltoo (Tmay beundefinedornull); for a variable, neither boxes the value.x instanceof Cmatches members whose class isCor a subclass. A member of a base class ofC, or an interface value, is tested at run time and narrows toC(downcasts).x instanceof Promisematches the promise member (View | Promise<View>), when no class namedPromiseis in scope.x == literal/x != literalselects the literal's member."key" in xkeeps the members that may have the key (declare it, optionally or not) and!("key" in x)those that may lack it (theinoperator). On a narrowedxonly the members left count (typeof x === "object" && "root" in xon a union withstringandbooleanmembers).typeoftests narrow a field path of a local too (typeof node.text === "string",this.label,h.node.text), as in TypeScript, until the path or a part of it is assigned. A call between the test and a read may change the field, so each read checks the member again and stops the program if the field now holds another member (Node would go on with the new value). A field narrowed to its function member can be called (typeof p.f === "function" ? p.f() : p.f), and one narrowed to some of its members reads as the union of them (with itsnullorundefinedwhen no test ruled that out).- A
U | nullvariable narrowed to some ofU's members (typeof x !== "number", thefalsebranch of a type predicate) is a value of the union of those members with itsnull:return xwhere the return type isVNode | string | null. - Conditions of
if,while,&&,||,!, ternaries and early exits narrow a local until it is reassigned;a || bnarrows to the members either side allows (typeof c === "string" || typeof c === "number"leavescastring | number);switchnarrows each case (switch). A local that a closure assigns is not narrowed by these tests (variables a closure assigns). - Assigning a member's value narrows to that member until the next assignment or join, also
in the declaration (
let u: string | number = "x"; u.length,else { p = new B(); } p.b), as in TypeScript.
class NotFound {
id: string;
constructor(id: string) {
this.id = id;
}
}
function find(id: string): string | NotFound {
return id == "1" ? "ann" : new NotFound(id);
}
function show(v: string | i64): string {
if (typeof v === "string") {
return v.toUpperCase();
}
return `${v + 1}`;
}
const r = find("2");
if (r instanceof NotFound) {
console.log("missing", r.id);
} else {
console.log(r);
}
console.log(show("a"), show(41));Discriminated unions
A union of object types (anonymous object types, structs, classes or interfaces) whose members
all have a field of a literal type is a discriminated union; that field (any name; kind
below) is the discriminant. It is compiled like a Rust enum: the discriminant is the tag, not a
stored string.
type Shape =
| { kind: "circle"; r: f64 }
| { kind: "rect"; w: f64; h: f64 };
function area(s: Shape): f64 {
switch (s.kind) {
case "circle":
return Math.PI * s.r * s.r; // s is the circle member here
case "rect":
return s.w * s.h;
} // every kind handled: no return needed after the switch
}
function isRound(s: Shape): bool {
return s.kind === "circle";
}
const shapes: Shape[] = [{ kind: "circle", r: 1.0 }, { kind: "rect", w: 2.0, h: 3.0 }];
for (const s of shapes) {
console.log(s.kind, area(s), isRound(s));
}- An object literal where the union is expected picks its member by the discriminant's literal
(else by its field names); an impossible discriminant is an error
(
"square"` is not a valid `kind` for `Shape`). x.kind === "circle"/!==andswitch (x.kind)narrow a localx; comparing with an impossible literal is an error. Abooldiscriminant is also a condition:if (r.done)/if (!r.done)narrowrof{ value: T; done: false } | { done: true }.- A field every member has (like
kind) can be read without narrowing; other fields need narrowing (no field `r` on type `Shape`), exceptvalueon anIteratorResult<T>, which reads asT | null(Iterables). Fields cannot be assigned through the union. - Members may be interfaces with methods, and intersections of one with object types
(
Base<T> & { state: "errored" } & ({ value: T; hasValue: true } | { value: null; hasValue: false }), one member per part of the inner union). A field every member has reads through each one's getter, and a method every member has is called on the union: through an interface every member extends that declares it, else each member's own when they have the same type. Members may be classes too (Circle | Sqwitharea()on both, or a union of classes only): each member's own method runs on the value itself: the receiver first, then the arguments in order, each evaluated once. The call's type is the union of the members' results (number | stringwhenscale()returns anumberonCircleand astringonSq), as in TypeScript. A getter every class of a union of classes has reads the same way (.sizeof aMap<K, V> | Set<T>).
interface Base {
describe(): string;
}
interface Circle extends Base {
readonly kind: "circle";
readonly r: number;
}
interface Square extends Base {
readonly kind: "square";
readonly side: number;
}
type Shape = Circle | Square;
const shapes: Shape[] = [
{ kind: "circle", r: 1, describe: () => "round" },
{ kind: "square", side: 2, describe: () => "four corners" },
];
for (const s of shapes) {
console.log(s.kind, s.describe()); // circle round, then square four corners
if (s.kind === "square") console.log(s.side); // 2
}- A discriminated union may name itself behind an indirection
(
type Tree = { kind: "leaf"; v: number } | { kind: "node"; kids: Tree[] }; see Union types). - Payload enums and
matchdo not exist; both are errors with a hint to use a discriminated union. - A live object can be cast to a union of interfaces (union views).
Union views
A live object can be a union of interfaces: when every member is an interface with methods and
the members share an interface (Base above), one object whose getters change can be cast to
the union, as libraries build such state machines (impl as AsyncState<T>): wide as Shape
keeps the same object, and every discriminant test, switch and field read on the union calls
its getters again, so the union sees the object change, as in Node.
The cast needs the object's type to have every member's fields, each typed as the union of the
members' types (kind: "circle" | "square"), and the methods of the shared interface (a class
instance converts through an interface its class implements: new Wide() as Shape); else it is
an error.
A read after a test is checked against the member the test selected: an object whose state no
longer fits it (kind is "circle" but r is null) gives a TypeError, never a value of
the wrong type. Values built from literals keep their member and its tag, and can sit next to
the live object in one variable or array.
Using a narrowed union that may hold such an object as a value of its member type (passing it
where a Circle is expected) is not supported yet (an error); read its fields instead, or pass it where the union
is expected.
interface Base {
describe(): string;
}
interface Circle extends Base {
readonly kind: "circle";
readonly r: number;
}
interface Square extends Base {
readonly kind: "square";
readonly side: number;
}
type Shape = Circle | Square;
interface Wide {
readonly kind: "circle" | "square";
readonly r: number;
readonly side: number;
describe(): string;
}
let kind: "circle" | "square" = "circle";
const wide: Wide = {
get kind() {
return kind;
},
r: 1,
side: 2,
describe: () => "changes",
};
const live = wide as Shape;
console.log(live.kind, live.describe()); // circle changes
kind = "square";
if (live.kind === "square") console.log(live.side); // 2Type predicates
A function whose return type is a type predicate x is T (TypeScript's user-defined type
guard) returns a boolean; a call of it in a condition (if, while, ?:, &&, ||, !)
narrows the variable passed as x, as a typeof or instanceof test would: to the union
members that are T (or to the subclass T), and to the others when the call is false. A
T | null variable narrows to T.
function isString(v: string | number): v is string {
return typeof v === "string";
}
function show(v: string | number): string {
if (isString(v)) {
return v.toUpperCase();
}
return (v + 1).toString();
}
console.log(show("ab"), show(41)); // AB 42- TypeScript does not check that a predicate's body answers correctly. A call that narrows
checks the value at run time against what its result says, as
typeoforinstanceofwould, and panics when they disagree (the type predicate `isString` returned false for a value that is `string`; exit code 101), where Node would go on with a value that is not of the type it was narrowed to. Unlike a thrown error, this panic cannot be caught withtry/catch. A predicate that answers correctly behaves as in Node. - A literal predicate type (
x is "a"onstring | number) narrows to the member it is a value of (string) when the call is true and leaves the variable as it is when false; a type no member of the variable's type is (x is Dogonstring | number) does not narrow. An object type that is no member narrows, when the call is true, to the members whose values have every required member of it:v is { length: number }onstring | numbertostring(strings, arrays and functions havelength), andv is Computed<unknown>on aJSXChild(primitives, arrays and object types withoutvalue) to nothing, as TypeScript's intersection with them has no value Velt can hold: the code there type-checks (v.value) and does not run, and a predicate that returns true anyway panics as above. A union with an interface value or a class instance isn't narrowed. When false, the variable is left as it is. - The function must be declared with
functionand not be generic for its calls to narrow; a predicate on an arrow or a function type ((x: unknown) => x is T) is just abooleanresult.this is Tis accepted and not used for narrowing yet. asserts x is Tandasserts xdeclare an assertion function, which returns nothing (it throws instead of returningfalse). A call of it as a statement narrows the variable passed asxfor the rest of the block, asif (!isT(x)) throw …;would: toT, or forasserts xto a truthy value (string | nulltostring). As for a predicate, the value is checked after the call, and an assertion function that returns for a value that is notTmakes the call panic (the assertion function `assertDog` returned for a value that is not `Dog`; exit code 101, whichtry/catchcannot catch). The errors the function throws itself are ordinary errors.
function assertIsString(v: string | number): asserts v is string {
if (typeof v !== "string") throw new Error("not a string");
}
function shout(v: string | number): string {
assertIsString(v);
return v.toUpperCase();
}
console.log(shout("hi")); // HIIntersection types
A & B is the object type with the fields of both A and B, as in TypeScript. The parts are
object types: anonymous ones, aliases of them, interfaces with only fields, results of utility
types, other intersections, and unions of these. & binds tighter than |
(A & B | C is (A & B) | C), and a leading & is allowed like a leading |.
type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged; // { name: string; age: number }
interface HasId {
id: string;
}
type Entity = HasId & { createdAt: number };
type WithMeta<T> = T & { meta: string };
const ada: Person = { name: "Ada", age: 36 };
console.log(JSON.stringify(ada)); // {"name":"Ada","age":36}
const e: Entity = { id: "e1", createdAt: 1700 };
const n: Named = { name: "Grace" };
const grace: Named & Aged = { ...n, age: 45 }; // spread builds one from the parts
const w: WithMeta<{ x: number }> = { x: 3, meta: "m" };
console.log(e.id, grace.age, w.meta); // e1 45 m- Fields are the first part's, then the next part's new ones: the key order of
{ ...a, ...b }, so printing andJSON.stringifymatch Node. - A field in several parts gets the intersection of its types: the same type stays, object
types merge (
{ p: { x } } & { p: { y } }hasp: { x; y }), a literal type and its base type give the literal, and union members that have no value in common drop out. The field is optional only when it is optional in every part, andreadonlyonly when it isreadonlyin every part that has it (both as in TypeScript). - Unions distribute:
(Circle | Square) & { id: string }is(Circle & { id: string }) | (Square & { id: string }), a discriminated union that narrows as usual;Shape & { kind: "circle" }keeps only the circle member, and(A | null) & BisA & B. - The result is an ordinary object type: there is no cost at run time, and
A & Bis the same type as the object type with those fields written out in the same order. - Conversions: a value converts to an object type whose fields it has, by name, as in TypeScript, as a new object (conversions between object types); an extension that adds only optional fields shares its base's object (extensions with optional fields).
- An alias may refer to itself through
&when its parts are object types written out (recursive intersection aliases).
type Named = { name: string };
type Scored = { score: number };
function greet(n: Named): string {
return `hi ${n.name}`;
}
function rank(p: Scored & Named): string {
return `${p.name}: ${p.score}`;
}
type Tree = { kids: Tree[] } & { v: number };
const ken: Named & Scored = { name: "Ken", score: 7 };
console.log(greet(ken), rank(ken)); // hi Ken Ken: 7
const t: Tree = { kids: [{ kids: [], v: 2 }], v: 1 };
console.log(t.kids[0].v); // 2A part may be a type parameter (T & { $set: (v: T) => void }, TMain & TSub), in a
generic alias or written in a generic signature: the intersection is worked out where the type
arguments are known, so a caller sees the object type with both parts' fields, and a type
argument that can't be a part (a class, say) is reported there. A type argument inferred
through such a parameter comes from the fields the object part names (T = { n: number } for a
Signal<{ n: number }>), else from the argument's parts the pattern doesn't name (T = A
for a value declared A & { id: string }), else from the whole argument
(T = { a: number; id: string } for { a: 1, id: "x" } against T & { id: string }), as in
TypeScript. Such an argument is inferred from before the other arguments are checked, so
reset(s, { st: "done", data: null }) with reset<T>(s: Signal<T>, v: T) checks the literal
against the T that s fixes. any, unknown and
object parts intersect as in TypeScript: any & X is any, unknown & X is X, and
object & X is X when X is an object type (so T & object at an object type T is T).
object & X keeps only the members of X that are objects, and has no value (never) when
none is: T & object at number is never, so sigx's PrimitiveSignal<T> | Signal<T & object>
at a number is PrimitiveSignal<number>; never & X is never. In a generic body,
T & object is T under a T extends object bound (an overload returning Signal<T> fits an
implementation returning Signal<T & object>), and a value of T & object (or of an
intersection with such a part) is an object, so it can be a WeakMap key. So is an
intersection with a part that is an object type and no part that is a type parameter
({ value: T } & { $set(v: T): void }): it satisfies T extends object. T & { __brand: "Id" }
with an unbounded T is not an object (at T = string it is a branded string).
In a generic body, a value of such an intersection may be returned or passed as T, or as one
of its object parts (s: T & { $set } as T, as { $set }), as TypeScript allows: it is the
same value where both are one type at an instantiation, and otherwise a new object with the
fields of the expected type. TypeScript passes the same object there, so such a copy is an
error where the program could tell it from the original: comparing values of the expected type
with ===, assigning a field of either object, printing or serializing it (the copy lacks the
other fields).
Type-level forms and keys in generic code. A mapped type, T[K], keyof T or a utility
type over a type parameter (Partial<T>, Pick<T, K>, …) may be written in a generic function's
signature or body, not only in an alias: it is evaluated at every instantiation, as TypeScript
evaluates it there. A key parameter K extends keyof T is inferred as the literal key the call
passes (the union of the keys when several arguments pass one: swap(o, "x", "y") has
K = "x" | "y"), and x[k] reads or writes that field of x: T, the field itself (an array
read through it is === to the original; a write reaches the object, a value a call returned
too: first(os)[key()] = v). The object and the key may be any expressions, each evaluated
once, in order. An optional field reads as F | undefined, and typeof, === undefined and
== null on a T[K] value test the instantiation's value (typeof o[k] is "undefined" for an
absent optional field, "number" for a present one). A key type of several keys reads or
writes the field the key holds (k: "x" | "y" on { x: number; y: number }). When those
fields have different types, T[K] is the union of them (number | string for "id" | "name"
on { id: number; name: string }): a read gives the field the key names as that union, and a
write takes the value's member when it is the field's type, and otherwise stops the program
with a TypeError where TypeScript would store a value of the wrong type (setField(u, k, 5)
with k holding "name"). A key of an optional field reads as F | undefined.
An object of such a form is built as TypeScript's standard pick builds it:
const r = {} as Pick<T, K> (or Partial<T>, Omit<T, K>, any object type over a type
parameter), keys set with r[k] = o[k], then returned as that type. {} as D is a dictionary
until then; the conversion to D takes each of D's fields from it (checked like a conversion
from any), and a key of a required field that was never set stops the program with a
TypeError naming it, where TypeScript would hand out an object without it. Velt's object has
D's fields in D's order, where JavaScript's has the keys in the order they were set: keys set
in another order (pick(o, ["name", "id"])), or a key D lacks ((r as any).extra = 5), stop
the program with a TypeError too, rather than print, serialize or list the keys differently.
function pick<T, K extends keyof T>(o: T, ks: K[]): Pick<T, K> {
const r = {} as Pick<T, K>;
for (const k of ks) r[k] = o[k];
return r;
}
console.log(pick({ id: 1, name: "a", age: 3 }, ["id", "name"])); // { id: 1, name: 'a' }Where Velt's fixed layouts cannot hold what TypeScript would store, generic code stops with a
TypeError that try does not catch:
- a write
x[k] = vthrough a union of keys, of a value whose type is not the field's (swapKeys(q, "id" as keyof U, "name" as keyof U), which Node stores); - a write of
nullorundefinedthrough such a key into a field that cannot hold it (undefinedinto a required field or anumber | nullone,nullinto ab?: number); {} as Dwith a required key never set, keys set in another order thanD's fields, or a keyDlacks;- a value of another type written into that dictionary through
any; - a cast to
T & X(x as Signal<T & object>) of a value that is not one at the instantiation, which is checked at run time; as T & objectofnull.
These stops are not exceptions: a catch around the call is never reached, and the program
exits with status 101 after printing the TypeError.
An error at an instantiation is reported in the generic function,
where the failing form is, with the call that instantiates it and the type arguments (and each
call on the way, when another generic function's body makes the call). A call whose argument
breaks a type parameter's constraint (get(p, "w"), a key p lacks) is one error, at that
argument, as in TypeScript, and is not instantiated. An alias
declared in a generic function's body may name the function's type parameters.
function get<T extends object, K extends keyof T>(source: T, key: K): T[K] {
return source[key];
}
function set<T extends object, K extends keyof T>(target: T, key: K, v: T[K]): void {
target[key] = v;
}
function update<T extends object>(x: T, patch: Partial<T>): T {
return { ...x, ...patch };
}
const st = { count: 0, name: "a" };
set(st, "count", get(st, "count") + 2);
console.log(st.count, JSON.stringify(update(st, { name: "b" }))); // 2 {"count":2,"name":"b"}Spreads in generic code. { ...t } and { ...t, tag: "x" } with t: T build a new object
at each instantiation, as Node copies an object's own properties: the fields of T's type
argument, then the explicit ones (a later field overrides an earlier one and keeps its position),
with nested objects and arrays shared rather than copied. The literal is a T, or a
T & { tag: string }, as in TypeScript, so explicit properties are checked against the expected
type ($set: (v) => … gets its parameter type from Signal<T>). An instantiation whose T is
not an object type ({ ...t } at T = number) is an error there. An optional field of a later
part overrides an earlier value of it only while present, as in Node: an absent name keeps the
earlier one. A field required in an earlier part keeps its type under a later optional one
(n: number, as TypeScript types it; both parts must give the field one type), so, as for a
concrete spread, a present undefined over it is a TypeError: Node keeps undefined in a
field typed number, which Velt can't represent. A spread used directly as a T
(return { ...x, ...patch } from a function returning T) is built as T the same way. A source
whose type argument is an interface with methods is an error at that instantiation: a spread
copies an object's own properties, which are its methods for an object literal but not for a
class instance, and an interface value may be either. A source of a concrete type that may be
undefined or null (...v with v: V | undefined) spreads nothing while missing, as in Node;
one whose type has a type parameter (x: T | undefined) is not supported yet: test it
first (x === undefined ? {} : { ...x }).
type Signal<T> = T & { $set: (v: T) => void };
function withSet<T extends object>(t: T, log: string[]): Signal<T> {
return {
...t,
$set: (v) => {
log.push(JSON.stringify(v));
},
};
}
const log: string[] = [];
const s = withSet({ n: 1 }, log);
s.$set({ n: 2 });
console.log(s.n, log); // 1 [ '{"n":2}' ]A value of a generic body used as one of its parts (s: T & { tag: string } returned as a T or
passed as a { tag: string }, { ...t } returned as a T) is the same value. TypeScript always
passes the same object; Velt's object types have fixed layouts, so this is allowed only at
instantiations where both are one object type (a T that already has tag, or { ...t } at any
object type), and an instantiation where they differ (T = { a: number }, whose values would
have to be copied, losing identity and writes) is an error at the generic function. x as T & object, for an x of a type parameter T without an
object bound, is checked at run time: the value itself (not a copy) when it is an object, a
TypeError when it is a primitive, null or undefined (TypeScript keeps such a value; write the cast after a test
that rules primitives out, as sigx's signal does). A generic alias used at
type parameters (Signal<{ value: V }> in function f<V>) is the object type its intersection
makes, as if written out. So is a generic function's result Signal<T> at a T that mentions
the caller's type parameters (signal({ st: "idle", data: d as D | null }) in
function cell<D>): its fields are read, written, destructured and reached with ?., and
$set is called, on the same value. Omit<T, never> and Pick<T, never> name no fields.
type Signal<T> = T & { $set: (v: T) => void };
function relay<T extends object>(s: Signal<T>): Signal<T> {
return s;
}
function stamp<T>(x: T & { id: string }): T & { id: string } {
return x;
}
let last = 0;
const s: Signal<{ n: number }> = {
n: 1,
$set: (v) => {
last = v.n;
},
};
const r = relay(s); // Signal<{ n: number }>: `n` and `$set`
r.$set({ n: r.n + 1 });
const t = stamp({ a: 1, id: "x" });
console.log(last, t.a, t.id); // 2 1 xDifferences from TypeScript, each a compile error with a note on what to write instead:
- A field that is a type parameter in one part and a type in another (
{ a: D } & { a: string }) isDwhenD's constraint is that type (D extends string), the deferredD & XwhenXis an object type or a type parameter, and an error otherwise. Two function types in one field (nestedSignal<Signal<T>>'s$sets) are one function when one of them takes every argument the other does (its parameters have a subset of the other's fields); other overloads are errors. - When the parts have no value in common (
{ k: string } & { k: number }, or two different discriminants), TypeScript makes the type, or the field,never; Velt reportsno value has type `…`: field `k` is `string` in one part and `f64` in another. - A class instance with object types (
Map<K, V> & { $set(v: Map<K, V>): void }, sigx'sSignal<T>at aMap, orT & { … }at a classT) is a brand of the instance, as a primitive's is (branded types): the value is the instance itself (x assuch a type keeps the object,===holds), with the class's members,instanceof, iteration, spreads, printing and the reflective operations (Object.keys,for...in,in,Object.hasOwn,Object.assign,{ ...x },JSON.stringifysee the class's fields). It converts to the class; an instance converts to it implicitly when every member of the object part is optional or a field, method or getter of the class of that type (C & { y?: number },C & { inc(): number }), else only withas. When the object part adds nothing to the class (C & { id: string }whereCdeclaresid: string) the type isCitself. A member only the object part declares (s.$set(v)) is read from the object at run time, as throughany, and checked as the member's type then: a proxy'sgettrap can provide it, and an instance that lacks it stops with aTypeErrorat the read (Node readsundefined). - Two classes (
A & B) and structs are not parts (Velt classes are nominal, not structural): usePick<C, …>or a field-only interface. Arrays (alsoSignal<T>at an arrayT, reported at the call) and function types (overloads) are not parts either;T extends A & Bstays a bound on two interfaces. - An interface with methods is a part, with rules of its own for classes that implement it, accessors and conflicting members (interfaces as parts).
- In generic code, a value of an intersection with a type parameter part (
x: T & { id: string }) is passed on and returned as it is, and the members of its object parts are read (also with?.and destructuring), written and called on the value itself (x.id = "b",s.$set(v)fors: Signal<T>); its conversion to one part (Tor{ id: string }) and building one ({ ...t, id }) are not supported yet. - An intersection is the same type as the object type with its fields written out, so where a
program has both forms, inference through
T & { id: string }treats them alike: an object literal{ a: 1, id: "x" }givesT = Awhen the program also declares a value of typeA & { id: string }(TypeScript:{ a: number; id: string }). It differs only where code depends onTitself, such asJSON.parse(s)returned as aT. - Interface declarations merge as in TypeScript (declaration merging).
type Conflict = { k: string } & { k: number }; // error: no value has type ...Conversions between object types
A value converts to an object type whose fields it has, by name, as in TypeScript: A & B
where an A or a B is expected, B & A (or { b; a }) where A & B is expected, and to a
type with an optional field the value lacks (it is absent). Object types have fixed layouts, so
the conversion builds a new object of the expected type holding the same field values: one
allocation and a copy of each field, paid where a program converts (nested objects and arrays
are shared, not copied).
TypeScript passes the same object, so where the program could tell the difference, the
conversion is an error with the fix (build the object from its fields, { a: ab.a }, or take
the wider type). That is where the program:
- assigns a copied field of either type (
a.a += 1on anAanywhere), or an optional field the value lacks (x.c = "s"on an{ a: number; c?: string }; a write throughany,x.a = 1orx[k] = vwithx: any, counts only when the program converts the copy, its original or a value that may hold one toany,unknownorobject, since only such objects are reached through one); - compares values of the expected type with
===(also asA | nullor another union holding it); - prints, serializes or lists the keys of a value holding the expected type;
- spreads a value of the expected type (
{ ...x, c: 3 }; Node would show or copy the original's fields, in its order); - tests a value of the expected type with
infor a key the copy left out ("b" in a, which Node finds on the original).
Each counts directly or in generic code the program calls (xs.indexOf(x) and xs.includes(x)
compare with ===). Keys compared by identity count as ===: a WeakMap, WeakSet or
WeakRef of the expected type. So does converting a value that may be the copy (followed
through variables, parameters and results; anywhere once the copy is stored in a field or an
array) to any, unknown or object when the program compares two such values that may be
objects ((a as any) === (ab as any), not x !== undefined), keys a Set<any>,
Map<object, V> or weak collection by them, or prints, spreads or lists the keys of one.
type A = { a: number };
type B = { b: number };
const ab: A & B = { a: 1, b: 2 };
const a: A = ab; // error: this conversion copies, and the copy's field `a` is assigned below
a.a += 1;An array converts element by element only when fresh, like wider element
types: const ns: Named[] = roster(); for a roster() that
returns a new (Named & Scored)[]; copy another one with xs.map((p) => ({ name: p.name })).
Extensions with optional fields
For an interface that extends one object type (an interface with only fields, or an object alias
that refers to itself) and adds only optional fields the base lacks, as in
interface InternalVNode extends VNode { _ns?: boolean }, a program that converts between the
two (vnode as InternalVNode, passing a VNode where an InternalVNode is expected or the
reverse, also as VNode[] and InternalVNode[] or as the values of a Map) converts the same
object, as in TypeScript: a field written through either type is there through the other, and
=== holds.
For that, every object of the base's layout gets room for the extensions' fields, absent until
written: in a tree of 1,048,575 four-field VNodes with an extension adding five optional
fields, an object grows from 88 to 144 bytes and the peak
memory from 198.7 MB to 214.8 MB. An extension the program only declares (often one in a
dependency) costs nothing.
Each type keeps its own fields: VNode has no member _ns, and keyof VNode, mapped types
(Required<VNode>) and structural conversions see VNode's fields only. Printing and
JSON.stringify show the whole object, an added field once it is written (one holding a
function is printed, and left out of JSON, as in Node).
Object.keys, for...in, spreads and in list the fields of the type the program looks
through, so one of those on a VNode is an error once the program may give its objects an added
field: assigns it, writes it in an object literal of an extension ({ ...v, _ns: true } too),
or makes such objects from text or a dynamic value (JSON.parse, x as VNode of an any),
whose keys are not known (look through v as InternalVNode).
Several such extensions of one base share it while they give a field of one name the same type;
one that gives another type (_subTree?: VNode next to _subTree?: VNode | null) keeps its own
layout and converts by copying, as conversions do, the
extension nearest to the base (by module path) sharing it. Neither the base nor the extension
may be generic. As for other optional fields written after construction, printing lists them in
declared order (the base's fields, then each extension's), not in the order they were written.
Recursive intersection aliases
An alias may refer to itself through & when its parts are object types written out:
type Tree = { kids: Tree[] } & { v: number } is the interface with the fields kids and v.
A part may also name a non-generic alias of such a type, or an interface with only fields,
declared in the same module (type Tree = Base & { kids: Tree[] } with
type Base = { v: number } or interface Base { v: number }). A field in two parts is one
field when its type is written the same in both (Base & { v: number; kids: Tree[] }, also for
function types; not yet for index or call signatures).
Object aliases may also refer to each other (type A = { b: B | null }; type B = { a: A[] },
or through a named part or an alias that renames another): each is an interface. An object
alias that names itself is one whatever other aliases name it back
(type Visitor = (t: Tree) => void).
A recursive alias is an error otherwise: with a part that is a type parameter, a generic alias
(Box<number> & { … }), an alias or interface of another module, an interface with methods,
or a field of two types; the error points at that part.
Interfaces as parts
An interface with methods is a part: I & { errors: number } (and A & B, or T & { … } at an
interface T) is an interface with every part's members, one whatever the order of the parts
(A & B is B & A).
An object literal implements it. A class that implements each interface part, itself or through
a base class (and has the object parts' fields, or get/set accessors for them, which reads
and writes through it run), converts to it as the same object, generic classes too, and a field
written through it reaches the class's field. A generic class's field provides a part's field
at the instance's type arguments (val: T of Cell<string> for { val: string }). Its values
convert to each interface part as the same object (=== holds).
Writing through it a field that a class has only a get for stops the program with Node's
TypeError: Cannot set property n of #<C> which has only a getter (what strict code gets), also
when a subclass overrides only the getter of its base's pair.
Pick<I, K> and Omit<I, K> of such an interface are interfaces with those members, which
values of I convert to the same way; in general an interface value converts to any interface
whose members it has (an interface it extends, a structurally equal one, in both directions).
Methods of one name with different signatures in two parts (overloads in TypeScript) are an
error, and so is a field in one part with a method of that name in another (no value has both),
and converting such a value to its object part ({ errors: number }), which would copy it. For
the same reason, a value of an interface that extends a field-only interface B does not
convert to B: B is an object type with a fixed layout, and TypeScript keeps the same object.
T & object at an interface or class T is T: t as T & object is the same value.
Changing a part of a field the class provides with get/set through the interface
(x.o.v += 1) is an error: Node changes the object the getter returns in place,
and Velt would change a copy and write it back through the setter.
Branded types
A primitive & an object type (string & { __brand: "UserId" }) is a branded type: a
nominal alias of the primitive, with no cost at run time. x as UserId brands a value; a
branded value works wherever its primitive does (members, operators, ${}, arguments, map
keys, typeof, also in generic code: typeof x of an x: T & { n?: number } at T = number is
"number"); a plain string, or another brand of it, does not convert to the brand.
type UserId = string & { __brand: "UserId" };
type Cents = number & { readonly __unit: "cents" };
function greet(id: UserId): string {
return `user ${id}`;
}
const id = "u-42" as UserId;
console.log(greet(id), id.length, id.toUpperCase()); // user u-42 4 U-42
const price = 449 as Cents;
console.log(price / 100); // 4.49type UserId = string & { __brand: "UserId" };
const id: UserId = "u-1"; // error: a plain `string` does not convert to it; brand a value with `x as UserId`Indexed access types
T["k"] is the type of field k of a concrete object type T, and T["a" | "b"] the union
of the fields' types, as in TypeScript: Person["name"] is string, and an optional field
email?: string gives string | undefined. On an interface with methods it reads a field or
a getter (get size(): number gives number). A key that is not a field
is an error. The key is a string literal type or a union of them; on a type parameter it is not
supported yet.
Type queries and keyof
typeof x in a type is the type of the value x, as in TypeScript: a module constant, a
function, a local variable or parameter (in a function body), or a field of one (typeof config.port); typeof ns.f names an export of a namespace import. A constant initialized with a
string or boolean literal has that literal type (const s = "a" gives typeof s, the type
"a"). With a number literal it is number, where TypeScript gives the literal type (const N = 4 gives 4; number literal types are not complete yet), so / on it divides as in
TypeScript. A function's type
includes the result its body gives, so ReturnType<typeof f> names a result type nobody wrote
down, even in the signature of another function, declared before or after it. A signature that
needs its own type (function f(x: ReturnType<typeof f>), also through other functions) is an
error, as in TypeScript (TS2502). ReturnType<F> is the result type of function type F, and
Parameters<F> its parameter types as a tuple. typeof f of a function with a rest, optional or
default parameter is not supported yet. In a field of an interface, object type or class
(signal: typeof signal), typeof f is the type the function's declaration writes: its
parameter types and its written result, and no error (a function there needs a written result,
and one that throws needs its throws written in a function type instead). typeof of a
symbol constant (const K = Symbol("k")) is symbol there too.
keyof T is the union of an object type's keys as string literal types (keyof { a: number; b: string } is "a" | "b"; a key a mapped type made from a number literal stays that number:
keyof { [K in 1 | 2]: K } is 1 | 2), and on a Record with literal keys it is the key type.
const DEFAULTS = { host: "localhost", port: 8080 };
type Settings = typeof DEFAULTS; // { host: string; port: number }
function parse(line: string) {
const [key, value] = line.split("=");
return { key, value };
}
function show(entry: ReturnType<typeof parse>): string {
return `${entry.key} -> ${entry.value}`;
}
function read(s: Settings, k: keyof typeof DEFAULTS): string {
return `${s[k]}`;
}
const s: Settings = { host: "example.org", port: 443 };
console.log(show(parse("a=1")), read(s, "port")); // a -> 1 443A function generic only over the errors its callbacks throw (setTimeout<E>(task: () => Promise<void, E>, ms: i64)) has the function type without them (typeof setTimeout takes a
callback that does not throw), as TypeScript declares such functions without type parameters.
Not supported yet (each is an error): typeof of another generic function, of an async or
generator function (function types have no type parameters), of a class or enum itself (TypeScript's constructor
and enum object types; the type of a class's instances is its name), of this and of a local
function value; typeof import("./m"); type arguments after the name (typeof f<string>);
typeof of a function or a constant without a type annotation in classes and interfaces (their
fields and methods); keyof of a class, of a type parameter and of a string-keyed Record
(TypeScript's string | number). A local's type is its declared type: TypeScript's typeof x
sees a narrowing (if (x !== null)) that Velt's doesn't.
Conditional, mapped and template literal types
TypeScript's type-level forms work as in TypeScript where their type arguments are known: an
alias like type Getters<T> = … is evaluated at each use (Getters<User>), at compile time, and
the result is an ordinary object type, union or literal type.
- Conditional types
C extends E ? T : F:Cis tested for assignability toE(structurally for object types). WhenCis a type parameter written alone, the test is made for each member of a union (booleanistrue | false;nevergivesnever);[C] extends [E]tests the whole type. As with--strict, function parameters are compared contravariantly, an optional field does not fill a required one, an object type whose fields are all optional takes only an object type with none of its own or one field in common ({ b: 1 } extends { a?: 1 }is false),{}takes any value butnullandundefined, and number literals compare by value (1and1.0are one type). TwoRecords relate by their type arguments (the key the other way round), else by their fields:Record<"a" | "b", 1>extendsRecord<string, number>. infer XinEof a conditional type names the part ofCit stands for: in a generic type, an object type's field, a function type, a tuple, or a template literal pattern. Aninferthat finds nothing is TypeScript'sunknown, which Velt supports only asNonNullable<X>({}). Aninfer Xin several function parameters finds their intersection.- Mapped types
{ readonly [K in S as N]?: V }: one field per key of the literal unionS. Overkeyof X, each field keepsX's?andreadonlyunless+,-or a modifier says otherwise;-?also removesundefinedfrom the field's type.as Nrenames a key, and a key renamed toneveris dropped; keys renamed alike have the union of the values. A number literal key stays a number. Withkeyof Tfor a type parameterT, a union argument gives the union of each member's mapped type.T[K]of an optional field isF | undefined, as TypeScript reads it (inside these forms and outside them). - Template literal types
`on${K}`: the union of the strings they spell, withUppercase,Lowercase,CapitalizeandUncapitalize; afterextendsthey are patterns (K extends `model:${string}`,S extends `${infer F}${infer R}`). Abooleanhole is"true" | "false", and a hole of literal types each of them spelled out (`${boolean}x`is"truex" | "falsex"). NonNullable<T>,Exclude<T, U>andExtract<T, U>;Awaited<T>, whatawaitgives (the value of a promise, of each member of a union, also over a type parameter; a thenable that is not aPromisestays itself, asawaittakes only promises);string & KwithKa string literal type isK;keyofof a union is the keys its members share.
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type OptionalKeys<T> = { [K in keyof T]: undefined extends T[K] ? K : never }[keyof T];
type Unbox<T> = T extends { value: infer V } ? V : never;
type User = { name: string; age?: number };
const getters: Getters<User> = { getName: () => "Ann", getAge: () => 36 };
const key: OptionalKeys<User> = "age";
const n: Unbox<{ value: number }> = 42;
console.log(getters.getName(), key, n); // Ann age 42In generic code. An alias whose body needs an argument that is still a type parameter
(function f<T>(x: T): Getters<T>, a generic class's or interface's field props: PropsAccessor<TProps>) stays unevaluated, as in TypeScript, and is evaluated once the type
arguments are known: at a call, at new, and for every instantiation the program reaches. In the
generic body a value of such a type can be stored, passed and returned as that same type. Reading
a field of it, assigning it to another type, or assigning anything else to it is not supported yet
in the generic body, even where TypeScript allows it (return x from x: T into
PropsAccessor<T>, const s: string = t for t: IsString<T>, t.length): it reports an error.
Where TypeScript rejects such code too (TS2322, TS2339), the error is the same.
type PropsAccessor<P> = { readonly [K in keyof P]: P[K] };
type IsString<T> = T extends string ? "yes" : "no";
interface SetupContext<P> {
props: PropsAccessor<P>;
}
function setup<P>(props: PropsAccessor<P>): SetupContext<P> {
return { props };
}
function tag<T>(x: T, t: IsString<T>): IsString<T> {
return t;
}
const ctx = setup<{ title: string }>({ title: "hi" });
console.log(ctx.props.title, tag(1, "no")); // hi noNot supported yet (each is an error): these forms written directly (not through an alias) with an
argument that is still a type parameter (function f<T>(x: T extends string ? 1 : 2)); reading a
member of a deferred type in the generic body, which TypeScript answers from the type parameter's
constraint (T[K] with K extends keyof T, a mapped type over keyof T); a mapped type over an
array or tuple type, or over string; a template literal type with a string or number hole
outside a pattern.
Enums
TypeScript-style enums only:
- numeric
enum Color { Red, Green = 5, Blue }: auto-incremented;Color.Blue as i64is its number; printed as a number; - string
enum Dir { Up = "UP", Down = "DOWN" }: every member needs a string; printed and serialized as its string; converts tostring; noascast.
Enums are not generic and have no payloads; use a discriminated union for tagged data.
Objects, arrays, tuples and maps
- Object literals
{ name: "a", n: 1 }have anonymous object types{ name: string; n: i64 }with a fixed layout (a field access is one load). An object type accepts exactly its fields: extra fields are a type error, and adding a property later is an error (use aMapor aRecord). - Quoted property names: see Quoted property names.
- Key order of an object type is JavaScript's: field names that are array indices (
"0","404": canonical, up to 2^32 - 2) come first, ascending, then the others in declaration order.console.log,JSON.stringifyandObject.keysall follow it. So do aRecordand aJsonValueobject: their array-index keys come first, ascending, then the others in insertion order (JSON.parsetoo). - Generic object types are structural, as in TypeScript: an instance is the object type it
spells out, so with
type Box<T> = { v: T },Box<string>is{ v: string }, and so is the instance of a generic interface with only fields. An instance whose field isvoid(Box<void>, or sigx's__ref: TRefbrand withTRef = void) is accepted, as in TypeScript, but the field keeps no value: what is written to it (ref: null as any) is dropped, and a read of it isvoid(which can't be printed or tested). Printing the object directly or writing it as JSON (directly or through a generic) is a compile error, and printing it through a generic or anany, writing it as JSON through ananyorunknown, or reading the field through anany, stops with aTypeError, where Node shows the value written. Avoidfield written in an object type, a struct or a class, or a class instance with one (new Holder<void>()), is an error.
type Box<T> = { v: T };
function box<T>(v: T): Box<T> {
return { v };
}
const b: { v: string } = box("hi"); // `Box<string>` is `{ v: string }`
console.log(b.v); // hisatisfies
value satisfies T checks value against T, with T as its context (as
an annotation const x: T = value does), and is value: { version: "1" } satisfies Stamp.
Its type is T, as for the annotated x; TypeScript keeps the value's own, more precise
type instead (a field T declares as number | string but given a number is a number
after satisfies there), which Velt does not do yet.
readonly fields
In { readonly id: i64; name: string }, assigning id is an error
(cannot assign to `id`: it is a readonly field); like TypeScript's, the check is shallow
(u.tags.push(x) is fine). A value converts between a type and the same type without
readonly, in both directions, and stays the same object.
JSON.stringify(x)/JSON.parse<T>(s)are generated at compile time for numbers, bools, strings, literal types, arrays, tuples, enums, nullable values,Map<string, V>,Record<K, V>, structs, classes and anonymous objects (velt/encoding/json).JSON.stringifywrites a class's or struct'sprivatefields, as Node does, and skips ES private fields (#x).JSON.parse<T>cannot build a type with aprivateor#field (a compile error naming the field: decoding does not run the constructor). A type holding a std type's private state (a runtime handle) has no JSON form in either direction, so handles can't be forged from JSON.
struct Point {
x: i64;
y: i64;
}
const p: Point = { x: 1, y: 2 };
const moved = { ...p, y: 5, label: "moved" };
const [first, ...rest] = [10, 20, 30];
const counts = new Map<string, i64>();
for (const word of "a b a".split(" ")) {
counts.upsert(word, 1, (n) => n + 1);
}
for (const [word, n] of counts) {
console.log(word, n);
}
console.log(moved, first, rest, [3, 1, 2].map((x) => x * 2).filter((x) => x > 2));
const ports = new Map([["http", 80], ["https", 443]]); // Map<string, i64>
console.log(ports.get("https")); // 443Quoted property names
Quoted property names work as in TypeScript, for names that are not identifiers:
type Headers = { "content-type": string }, { "a-b": 1 }, interface A { "data-id": string }
and const { "a-b": n } = o. o["a-b"] (or o[`a-b`]) reads the field; with any string
literal, o["name"] is the same as o.name.
A key whose type is a string literal or a union of them works too, as in TypeScript:
const k = "a-b"; o[k] reads o["a-b"], and o[k] with k: "a" | "b" reads the field k
holds when it runs, typed as the union of the fields' types (number | string). Assigning
through such a key (o[k] = v, o[k] += 1, o[k]++) needs the fields to have one type;
o[k] += v reads the field before v runs, as JavaScript does. The key can be any expression
of such a type (o[pick()], o[keys[1]], h.o[h.k]), evaluated once; the object must be a
variable, this or a field of one.
console.log quotes the names that are not identifiers, as Node does ({ 'a-b': 1 }), and
JSON.stringify writes them as given.
Names beginning with # or [Symbol., "__proto__" (it sets the prototype in JavaScript) and
quoted method names are not supported. Parameter destructuring isn't supported yet, so quoted
names in it aren't either.
Methods in object literals
{ name(x: T): R { ... } } is a property holding the function, as { name: (x: T): R => { ... } }
is; async name() {} holds an async function. As in an arrow, the parameter types may be left
out where the literal's type gives them.
this in such a method, and getters and setters (get x() {}, set x(v) {}), are supported
where the literal implements an interface with methods
(Object literals implementing interfaces) and in
a literal with no expected type
(this in object-literal methods), not yet in a literal of
an object type.
A generic method (match<R>(arms: Arms<R>): R { ... }) is a property holding a
generic function value; in a literal that
implements an interface, such a method can't use this yet (an error says so).
A generator method *name() { ... } (async *name()) is a property holding a
generator function, as
name: function* () { ... } is; a generator method with a symbol key only as
*[Symbol.iterator]() (Iterable object literals).
Method signatures in object types
{ size(n: number): number; stop(): void } declares fields of function type, as
{ size: (n: number) => number; stop: () => void } does, and label?(p: string): string an
optional one (((p: string) => string) | undefined, absent when left out; call it as
o.label?.(p)). A generic method signature (run<T>(f: () => T): T) declares a method in an
interface; in an object type, and as an optional one in an interface (wrap?<A>(a: A): A), it
declares a field of generic function type (<T>(f: () => T) => T), a
generic function value.
A call signature alone (interface F { (x: T): R }, type F = { (x: T): R }) declares the
function type (x: T) => R: a value of it is called as f(x), any function or arrow of that
type is one, and typeof f is "function". With members, an object type or interface with
one call signature is a function with properties
(TypeScript's ((x: T) => R) & { stop(): void }). A parameter initializer in a call signature
((x = 1): void) is an error, as in TypeScript.
A generic call signature (<T>(x: T): T) is a generic function type, planned. A construct
signature (new (x: T): C; as in TypeScript, new(…) in an object type is never a method,
while in a class it is) is the type of a class used as a value, new (x: T) => C
(Functions).
Several call signatures (an overloaded function); optional parameters in a call signature
((x?: number): void). An interface with call signatures merges with the other declarations of
its name, but not yet in declare global or declare module, nor inside a function body.
interface Formatter {
(value: number, unit: string): string;
}
const fmt: Formatter = (value, unit) => `${value} ${unit}`;
console.log(fmt(3, "kg"), typeof fmt); // 3 kg functiontype Handler = {
get(target: string, key: string): number;
label?(prefix: string): string;
};
const h: Handler = {
get(target, key) {
return target.length + key.length;
},
};
console.log(h.get("ab", "c"), h.label?.("x") ?? "none"); // 3 noneUtility types
Utility types build an object type from a concrete one (an object type, an interface with only fields, or a class or struct, whose public fields are used):
Partial<T>: every field optional;Required<T>: no field optional;Readonly<T>: every fieldreadonly;Pick<T, K>: only the fields named inK;Omit<T, K>: every other field.
K is a string literal type or a union of them ("id" | "email"). In Pick a name that is
not a field is an error; in Omit it is a warning, as TypeScript accepts it (so
type WithoutChildren<P> = Omit<P, "children"> works on types without children).
The results are ordinary object types: Pick<User, "name"> is { name: string }, and
declaration order doesn't matter. As in TypeScript, Required removes only the ?: a field
written a: T | null, or a?: T | null, stays nullable.
Differences from TypeScript: an operator on a type parameter (Partial<T> in a generic
function) is not supported yet; and a type can't apply one to itself in its own fields
(interface Node { patches: Partial<Node>[] }).
interface User {
readonly id: i64;
name: string;
email?: string;
}
type Patch = Partial<User>; // { readonly id?: i64; name?: string; email?: string }
type Summary = Pick<User, "id" | "name">; // { readonly id: i64; name: string }
function apply(u: User, p: Patch): User {
return { id: u.id, name: p.name ?? u.name, email: p.email ?? u.email };
}
const s: Summary = { id: 1, name: "ann" };
console.log(apply({ id: s.id, name: s.name }, { email: "a@x" }).email); // a@xSpread
{ ...a, b: 1 } builds a merged object at compile time (later keys win); [x, ...xs] builds a
new array, converting each element to the expected element type (integer elements spread into a
number[], const ns: Named[] = [...cs]). Spread arguments, f(...xs), fill a rest parameter
(Functions). Whatever for...of takes can be spread into an array
or a rest parameter too: [..."héllo"] (characters), [...map] (entries), [...gen()],
Math.max(...set) (Consuming an iterable).
Elements, arguments and properties run in written order, as in JS: [f(), ...g()] calls f
first, an iterable is consumed where it is written, and { y: f(), x: g() } calls f first
whatever the order of its type's fields; a property a later spread overrides ({ a: f(), ...o })
still runs. A spread copies its source's elements or properties where it is written, before
later ones run: { ...o, a: f(o) } and [...xs, ...f(xs)] copy o and xs before f changes
them, and [n, ...bump()] reads n before bump does.
Wider element types
An array, object type or generic class converts to the same type with wider elements (C[] to
Named[] for a class C implements Named, i64[] to (i64 | null)[], Box<C> to
Box<Named>) only when the value is fresh: a literal, a new expression, or the result of
a call of a function that returns a new value on every path (a literal, new, such a call, or
a local it builds and returns without storing or passing it anywhere, as map and filter do).
The conversion builds a new value with each element converted.
A call that may return a value something else still holds (a getter returning a field) is an
error with the same fix as below, and so is a conversion in a field initializer or a default
value for now. TypeScript also converts an existing array, which is unsound: storing a Named
that is not a C through the Named[] would put it into the C[]. Velt reports that and
suggests a copy, [...cs] or cs.map((x): Named => x).
An as cast of an existing array to another element type (cs as Named[]) is allowed and gives
a view of the array (see "any and unknown" below), checked element by element as it is read and
written. A generic class converts only when it has no base class, no subclasses and no
[Symbol.dispose](); the new object shares the old one's field values.
interface Named {
name(): string;
}
class C implements Named {
name(): string {
return "c";
}
}
function make(): C[] {
return [new C()];
}
const ns: Named[] = make(); // a fresh C[]: converted
const cs = make();
const copy: Named[] = [...cs]; // `const ns2: Named[] = cs;` is an error
console.log(ns.length, copy.length); // 1 1Destructuring
Destructuring takes a value apart: const [a, b] = pair;, const [head, ...rest] = xs;,
const { a, b } = obj;, and for (const [k, v] of map). Array destructuring checks the length
like indexing: a shorter array panics with the same index out of bounds message. A string, a
map or an iterable is destructured like in JS (const [first, ...rest] = "abc"):
const [a, b] = gen() takes two values and closes the iterator; one that has fewer values
panics like a short array, unless the pattern gives defaults. Nested patterns work too
(const [[a, b], [c]] = [gen(), gen()]).
An object pattern reads properties, as in JS: const { length } = xs; and
const { length: n } = "abcd"; read the length, and a getter is called
(const { area } = rect;). A method or function-typed member destructured out of an object or
interface value is a function value (const { insert, remove } = options;), bound to the object
it was read from (in JS it is unbound, so the two differ only where JS would throw a TypeError
for a method that uses this).
As in TypeScript, an array pattern over an array literal types the literal as a tuple
(const [n, s] = [1, "s"] gives a number and a string, also in a destructuring assignment,
for […] as const and for a const c = [2, "y"] as const taken apart, also in a closure or
function that captures c and where c is a module constant; c itself stays a read-only
array of the union, with length, indexing, iteration and the read methods). Taking c apart
reads its elements, so a write made through a cast
((c as unknown as (number | string)[])[0] = 7) is seen; an element overwritten with a value of
another type stops with a TypeError.
Destructuring an any reads each element or property through it (const [a, b] = v is v[0],
v[1], each an any, read once; a default applies where the read gives undefined; a
...rest of an any is not supported yet).
A computed key naming a variable (const { [k]: v = 0 } = r, ({ [k]: p } = r), a local, an
outer function's or a module variable, read when the pattern runs) reads r[k]; one naming a
module constant holding a string or a symbol ([KEY]) reads that member, also of an object,
interface or class; a string, number or template key ({ ["a"]: v }, { [0]: v }), like a
quoted or numeric property name ({ "a": v, 0: w }), names that property. Any other key
expression ({ [f()]: v }) is an error: store the key in a variable first.
On an array or a tuple a numeric key reads that element and length the length, a tuple's
arity (const { 0: first, length } = xs, also with defaults, nested, also inside a tuple
element, const [i, { 0: c }] = t, in for...of and in parameters). Defaults on a Record or
index-signature pattern apply where the key is missing (const { port = 80 } = env).
A ...rest of an any; ...rest in a pattern with numeric keys on an array or a tuple, and a
numeric key above 4096 there; a ...rest next to a computed key.
Object rest
const { children, ...rest } = props; (also in let, nested patterns, next to defaults and in
parameters, function f({ id, ...rest }: P)): rest holds the other own properties, in the
object's order, as in JS. The rest is a new object: its fields are copied (a nested object or
array is shared, as in JS).
Its type is TypeScript's: on an object type or an interface of fields, the object type of the
other fields (Omit<P, "children">; an optional field is copied only while present); on a class
instance, the other fields (its methods and getters live on the prototype, so the rest has
none, nor #private fields); on a Record<string, V>, a Record<string, V> of the other keys.
Each is an error: the rest of a value whose type is a type parameter, an interface with methods,
a record with a closed key type, or a class with a TypeScript private field the pattern doesn't
take (JS copies it, but the rest's type leaves it out), and ...rest in for...of patterns.
interface Props { id: number; title: string; hidden?: boolean }
const props: Props = { id: 1, title: "t" };
const { id, ...rest } = props;
console.log(id, rest); // 1 { title: 't' }Defaults
Defaults in const and let patterns: const { host = "localhost", port = 80 } = opts; takes
the default when the field is absent (optional) or undefined, and const [first = 0] = xs;
when the array is too short or the element is undefined, as in JS; a field or element declared
T | null keeps its null, and so does an optional field declared a?: T | null that holds
null. for...of patterns take defaults (and an object rest) the same way:
for (const { name, port = 80 } of servers), and so do parameter patterns
(function f({ a = 1 }: { a?: number })). A nested pattern's defaults apply the same way, also
inside a tuple element (const [i, { a = 5 }] = t with t: [number, { a?: number }]).
Destructuring assignment
A destructuring assignment takes a value apart into places that already exist, as in JS:
[a, b] = [b, a] swaps, and the targets may be variables, fields and elements
([xs[i], xs[j]] = [xs[j], xs[i]], [this.x, this.y] = [y, x], ({ w: box.width } = size)),
nested ([p.x, { y: p.inner.y }] = src). The value is evaluated first, then each target in
order, left to right, each assigned before the next is evaluated ([i, xs[i]] = [1, 5] writes
xs[1]).
Defaults ([a = 1, b = a] = xs, ({ host, port = 80 } = opts) with the shorthand
{ port = 80 }, which is only allowed in such a target, TS1312) apply where the value is
undefined, as in declarations, and each is evaluated when its turn comes, so it sees the
targets assigned before it ([a, b = a] = [5] gives 5 5). A last ...rest takes the other
elements or properties ([first, ...others] = xs, ({ id, ...rest } = p)), a hole skips an
element ([a, , b] = xs; a hole elsewhere, [1, , 2], is not supported), and an object target
needs parentheses as a statement (({ a, b } = o);), as in JS.
for...of and for...in assign to such targets too, and to a variable or place declared
elsewhere: for (x of xs), for ([k, v] of map), for ({ name, port = 80 } of servers). The
assignment has no value yet: x = [a, b] = c is an error
(a destructuring assignment cannot be used as a value yet; assign c to x first).
class Point { x = 1; y = 2; }
let a = 1, b = 2;
[a, b] = [b, a];
const xs = [10, 20, 30];
[xs[0], xs[2]] = [xs[2], xs[0]];
const p = new Point();
[p.x, p.y = p.x * 10] = [3];
let host = "", port = 0;
({ host, port = 80 } = { host: "h" } as { host: string; port?: number });
let first = 0, rest: number[] = [];
[first, , ...rest] = xs;
console.log(a, b, xs, p.x, p.y, host, port, first, rest); // 2 1 [ 30, 20, 10 ] 3 30 h 80 30 [ 10 ]
let k = "", v = 0;
for ([k, v] of new Map([["one", 1]])) console.log(k, v); // one 1Arrays
T[] has length and xs[i] (bounds-checked: panics
index out of bounds: the len is L but the index is I; a read where a T | undefined is
expected, return xs[0] from a function returning T | undefined or
const v: T | undefined = xs[i], and any read of an array whose element type allows
undefined, gives undefined outside the array instead, as in JS).
Its methods include push (any number of items: xs.push(a, b), xs.push(...ys), all
evaluated before the first is pushed, as in JS), pop(): T | null,
forEach map filter reduce find findIndex some every indexOf lastIndexOf includes slice concat reverse isEmpty entries fill,
join and toString() (any elements, written as ${xs} writes them), sort() on numbers,
strings and Comparable elements, and sort(cmp) (stable, any element type, like JS's
Array.prototype.sort(compareFn)). The full list is in the prelude.
Callbacks get the element and its index, like JS (xs.map((x, i) => …), the index a number,
also when written (x: number, i: number)), and may take fewer parameters. A predicate
(filter, find, findIndex, some, every) may return any value, tested for
truthiness as in JS:
[0, 3, 0, 4].filter((n) => n) is [3, 4].
Arrays, strings and maps are Iterable: they convert
to Iterable<T> values, and xs[Symbol.iterator]() returns an Iterator<T>.
new Array and Array.from
new Array<T>(n).fill(v) and Array.from({ length: n }, (_, i) => f(i)) build an array of
n elements in one allocation. A bare new Array<T>(n) is an error: arrays have no holes.
As in JS, new Array<T>(n) takes n as xs.length = n does: a negative, fractional or NaN
n panics with RangeError: Invalid array length, while Array.from truncates its length (and
takes a negative one or NaN as 0). A numeric enum's value is a length too. An argument that is
no number (a string, a string enum's value, an object) makes an array of that one element, as in
JS (new Array("3").fill("x") is ["x"]); one whose type may be either (number | string, an
unbounded type parameter) is an error, since JS decides between the two at run time: test
typeof n === "number" first. new Array(...args) with a tuple follows JS: one element is the
argument, any other count makes that many elements. Array.from(src) and Array.from(src, (v, i) => …) copy (and map) anything for...of takes:
an array, a string's characters, a map's entries, a generator, any iterable.
Length limit
An array holds at most 2^52 - 1 elements, and its elements take at most 2^52 - 1 bytes
together, where JS stops at 2^32 - 1 elements. Velt allows more on purpose: its arrays are
native buffers, so a byte array, a column of numbers or a buffer may be larger than 4 GiB, as in
Rust, while 2^52 bytes (4 PiB) is still beyond any machine's memory. So xs.length = 2 ** 32 is
a RangeError in Node but truncates a longer Velt array.
Growing an array past the limit (push, concat, splice, spread, Array.from,
new Array<T>(n)) panics with RangeError: Invalid array length: an array holds at most 2^52 - 1 elements, and its elements at most 2^52 - 1 bytes, and so does xs.length = n with an
n past 2^52 - 1 (also through any). An array within the limit but larger than the machine's
memory stops the program with memory allocation of <n> bytes failed, on every target (on
WebAssembly, any buffer past 2^31 - 1 bytes). The bound is also what lets the compiler count a
loop such as for (let i = 0; i <= xs.length; i++) in integers. Typed arrays hold at most
2^52 - 1 bytes.
readonly T[]
readonly T[] and ReadonlyArray<T> (the same type) are array types, and readonly [A, B] a
tuple type; a T[] is passed where one is expected. A variable or parameter declared with a
read-only array type cannot be written through: a mutating method
(property 'push' does not exist on type 'readonly number[]', TypeScript's TS2339, also
pop, shift, unshift, splice, sort, reverse, fill, copyWithin) and an element
assignment (xs[0] = x, TS2542) are errors; the elements' own fields may change
(xs[0].n = 1). The check follows the variable: a readonly T[] copied into a T[] variable
or passed as a T[] parameter (TypeScript's TS4104) is not reported yet.
ReadonlySet<T> and ReadonlyMap<K, V> are Set<T> and Map<K, V> seen through a type that
may not change them, as in TypeScript: a Set or Map is passed where one is expected
(const keys: ReadonlySet<string> = new Set(["a"])), and everything that reads it works (has,
get, size, forEach, keys(), for...of, new Map(m)). Through a variable or parameter
declared with one, the methods that change the collection (add, set, delete, clear,
upsert, update, getOrInsert) are TypeScript's TS2339
(property 'add' does not exist on type 'ReadonlySet<string>' (TypeScript's TS2339)), also
when it is declared through a type alias of the program (type Tags = ReadonlySet<string>, also
an alias of one, one in | null / | undefined, and a generic alias whose body is read-only,
type RO<T> = ReadonlySet<T>), made by a cast (new Set() as ReadonlySet<string>, also inline:
(xs as readonly T[]).push(x)), captured by a closure, narrowed (if (s), s!), read with
?., or copied into a variable without a type (const t = s); the same holds for
readonly T[]. A cast to a type that is not read-only (xs as string[], s as Set<string>)
may change the value, as a variable or inline, and so may a collection narrowed by
s instanceof Set (or Map) where the test holds.
An element of an array whose elements are read-only (k: readonly (readonly T[])[],
Array<ReadonlySet<T>>) is read-only too: k[0].push(x) is TS2339 and k[0][0] = x TS2542,
while writing into an element that may change (j[0].push(x), j[0].length = 1 with
j: readonly T[][]) is allowed, as in TypeScript; only replacing the element of a read-only
array (j[0] = []) is TS2542.
Not reported yet (TypeScript reports them): such a method called on a field, an element copied
into a variable (const e = k[0]), an element of an alias of the whole array type
(type Grid = readonly (readonly T[])[]), an element three levels deep, an element typed
| undefined read with ?. or !, a destructured or returned value or a for...of variable
of a read-only type, a method read as a value (const add = s.add), a read-only type given to
an alias whose body is its parameter (type Box<T> = T, then Box<ReadonlySet<string>>), and
passing or assigning a read-only collection where a Set or Map is expected (TS2345, TS2739).
Tuples
[A, B]: t[0], destructuring, printed like arrays. Element labels document the elements and
are otherwise ignored, as in TypeScript: type Wire = [kind: string, b64: string]. Optional
elements ([A, B?], [name: string, age?: number]) and rest elements (variadic tuple types,
[A, ...T], [first: string, ...rest: number[]]) parse, with TypeScript's errors for their
order (an optional element after a rest one, TS1266; a required one after an optional one,
TS1257), and using one is an error saying it is not supported yet. Promise.all over tuples of
different types is not supported.
Map<K, V>
new Map<K, V>(), new Map(entries) from an array of [key, value] tuples
(new Map([["a", 1], ["b", 2]]): as in JS, the array stays as it is, the map shares its keys
and values, and a repeated key keeps its first position and its last value) or from any
iterable of them (new Map(pairs())).
A map has set, get(k): V | undefined (the stored value itself, as in JS; undefined for a
missing key), has, delete, size, keys(), values(), entries(),
for (const [k, v] of m), plus single-lookup updates: upsert(k, init, (v) => v + 1),
update(k, (v) => { v.push(x); }): bool (the callback gets the stored value itself) and
getOrInsert(k, () => v) (Map). Iteration follows insertion order,
like JS.
Keys compare as in JS: numbers, bool and string by value, objects (class instances, object
types, arrays, maps, records, interface and function values) by identity, as === compares
them, so two equal object literals are two keys and a key changed after insertion keeps its
entry; tuples compare part by part. Structs are Velt value types: their keys compare and hash by
content, field by field, so an equal struct finds the entry. Float keys compare like JS's
(SameValueZero: 0 and -0 are one key, NaN finds itself).
A value type may include null (Map<string, number | null>, Record<string, T | null>): an
entry holding null is present like any other (has is true, it is counted, iterated and
printed), and get gives null for it and undefined for a missing key, as in JS. If such an
entry holding null has a content key that changed after insertion, then while the map has
deleted entries not yet compacted away, forEach, for...of and console.log skip it
(keys(), values() and entries() list it).
Record<K, V>
A dictionary written with object syntax, like TypeScript's Record. K is string, a union of
string literal types, or a string enum; any other key type is an error (use a Map), also when
a generic function or class gets it as a type argument. console.log and JSON treat a record
as an object. A class cannot extends a Record (its constructor would leave a closed record
without its keys); hold one in a field instead.
Number keys
Record<number, V> and Record<string | number, V> are Record<string, V>, as JavaScript makes
a number key its string: r[n] with a number n reads and writes r[String(n)] (r[1.5] is
r["1.5"]), and Object.keys lists the keys as strings, array indices first. Through a variable
or parameter declared with one, a string key that is not a number's name is an error, as in
TypeScript: r["abc"] (TS7015), r.abc (TS2339) and { a: v } as its initializer (TS2353);
r["1"] and r["1.5"] are keys. A number index works on any string-keyed record. A type
parameter given number as a record key is not supported yet.
Symbol keys and accessors
A string-keyed record also holds symbol keys, in a table of its own as in Node, made when the
first symbol key is written (a record without symbol keys pays nothing for it):
Record<string | symbol, V> and Record<PropertyKey, V> are Record<string, V>. r[s],
r[s] = v, r[s] ??= v, delete r[s], s in r and Object.hasOwn(r, s) take a symbol s (a
module constant, a well-known symbol such as Symbol.iterator, or one made at run time), a
string | symbol key or a PropertyKey (string | number | symbol, also a union of literals; a
number is its string), and a symbol constant is a key of a record literal ({ [KEY]: v }).
Object.keys, Object.values, Object.entries, for...in and JSON.stringify skip the
symbol keys; Object.getOwnPropertySymbols(r) lists them in insertion order, Reflect.ownKeys(r)
lists the string keys (array indices first) and then the symbols, and a spread copies them, as in
Node. console.log writes the symbol keys after the string keys as Node 26 does
({ a: 1, Symbol(k): 2 }).
Object.defineProperty(r, k, { get, set, configurable }) defines an accessor under a string or
symbol key, not enumerable (the listings above skip it, Reflect.ownKeys lists it after the
entries of its kind); reads, writes, in and delete run or test it, a write without a setter
and a delete or redefinition of an accessor that is not configurable are Node's TypeError.
The getter and setter may throw: the error leaves at the read or write that ran them, where no
try catches it yet, so it ends the process with Node's Uncaught report and exit
code 1. Object.defineProperty(r, k, { value, enumerable: true, writable: true, configurable: true }) writes an entry; an enumerable accessor and other attributes are not
supported yet. Object.getOwnPropertyDescriptor(r, k) describes an entry
({ value, writable: true, enumerable: true, configurable: true }) or an accessor
({ get, set, enumerable: false, configurable }), or is undefined.
In a program with proxies, a proxy whose target is a record forwards symbol keys to its symbol
table through Reflect, and indexing an any with a symbol key reads, writes and tests it
there. The methods for symbol keys and accessors are loaded only by a program that mentions
symbols, property definitions, Proxy or Reflect.
const K = Symbol("k");
const rec: Record<string, number> = { a: 1 };
rec[K] = 2;
console.log(rec, Object.keys(rec), Object.getOwnPropertySymbols(rec).length); // { a: 1, Symbol(k): 2 } [ 'a' ] 1Casts to records
A cast to Record<symbol, unknown> (or Record<symbol, any>) looks at a value's symbol-keyed
members, as through any: (v as Record<symbol, unknown>)[BRAND] === true reads the member
BRAND names, or undefined, as a value of the record's value type (unknown must be narrowed
before use, as TypeScript's TS2322 says). A string key (["a"], .a, TypeScript's TS2339) and
any key that is not a symbol (a string or symbol | string variable, TS7053) are errors; an
any key reads as through any. A key computed by a call or a condition and a well-known
symbol ([Symbol.iterator]) are not supported yet, and neither is any other use of the cast:
writing through it and keeping it are not supported yet.
On an any x, (x as Record<K, V>)[k] and (x as Record<K, V>).k read and write x itself,
as the assertion converts nothing in TypeScript: a read is a V | undefined (a V where one is
expected, a missing key then a TypeError), and a value written is checked as a V, also by
??=, ||= and &&= (and += on a number member takes a number).
Open and closed records
With string keys a record is open: r[k] and r.name are V | undefined, r[k] = v
inserts or replaces, r[k] ??= v sets a missing key, and delete r[k] removes. Because a key
may be missing, r[k] += 1, r[k]++ and the other compound assignments are errors on an open
record: say what a missing key starts from with r[k] = (r[k] ?? 0) + 1 (JS would give NaN).
With literal or enum keys it is closed: it always holds every key, so r.cpu is V,
r.cpu += 1 works, a typo is an error, and delete is not allowed. On an enum-keyed record,
r.mem names the member whose value is "mem".
In generic code, where the key type is a type parameter K, reads are V | undefined and the
record may be closed, so it cannot start empty (only a literal with a spread builds one) and
delete is not allowed.
Building records
Build a record from an object literal where a record is expected
(const r: Record<string, i64> = {}; a closed record's literal must list every key) or with
new Record<string, V>(). A literal may spread another record ({ ...r, x: 1 }) and have
computed keys ({ [k]: v, [a + b]: w }, in order with the other keys). An object literal with a
computed key that is not a module constant (a module let is read when the literal is built),
or one that spreads a variable r of type Record<string, V>, is a Record<string, V> also
where no record is expected (spreading a record into an object of a fixed shape is an error:
its keys are known only at run time).
A string or integer literal key in brackets is the plain key ({ ["a"]: 1 } is { a: 1 }), as
is a numeric property name, which is the number as String spells it ({ 7: v },
{ 0x10: v }, { 1.50: v } and { 1e3: v } have the keys "7", "16", "1.5" and
"1000", and { 9007199254740993: v } the key "9007199254740992", the nearest double). A
computed key is a string, or a number converted to its string as in JavaScript
({ [i + 0.5]: v } with i = 3 has the key "3.5"); a symbol made at run time is an error,
and so is a key of any other type ({ [true]: v }, TypeScript's TS2464).
A literal for an enum-keyed record.
Key order and Object.keys
A record has no methods of its own and is not iterable: Object.keys(r) (a string[]),
Object.values(r) and Object.entries(r) return arrays in JavaScript's order: keys that are
array indices ("0" to "4294967294") first, ascending, then the others in insertion order
(for (const [k, v] of Object.entries(r))). console.log and JSON.stringify use the same
order. Given an object literal, Object.values and Object.entries read it as a
Record<string, V>, so its values need one type.
Object.keys accepts any object, as in TypeScript: an object literal or object type
(Object.keys({ a: 1, b: "x" }) is ["a", "b"]), a struct, or a class instance, whose fields it
lists in declaration order (base class fields first, private ones too; not ES private #x
fields, static fields or methods). A struct's optional field is listed only when it is present.
On a class with subclasses it lists the fields of the object's actual class (a Shape holding a
Rect lists the Rect fields too), and on an interface value those of the class it holds (an
interface also implemented by a struct is an error: struct values carry no class).
On an any it lists the fields of the class instance held, when every value the program
converts to a dynamic type is a class instance; in a program that also converts other values
(an array, a string, an object type, a function) it is a compile error.
Index signatures
An object type or interface with an index signature and no other members,
{ [key: string]: V }, is Record<string, V>, as in TypeScript where the two are
interchangeable: a value converts between them and stays the same object, and everything said
of Record above holds (any key may be read, written and deleted; keys are listed in
JavaScript's order, array-index keys such as "2" first). A number key,
{ [id: number]: V }, is the same record: JavaScript makes a number key its string, so r[1] is
r["1"] (as for Record<number, V>). A generic interface works too
(interface Groups<T> { [name: string]: T[] }).
Reading a key that is not there gives undefined, as in JavaScript, so a read is
V | undefined (TypeScript types it V): d.k === undefined is true and typeof d.k is
"undefined" for a missing key, and d[k] ?? 0, d[k] || 0 and d[k]?.push(x) work as in
JavaScript. The value type may include null ({ [k: string]: number | null }): a key holding
null is present (listed by Object.keys, written by JSON.stringify) and reads null, a
missing key undefined.
Named members next to an index signature of any or unknown values
(interface Props { label: string; children?: string; [key: string]: any }) make the type that
record too, as every member's type is one the values have: a named member is a key like any
other (written, deleted, listed and spread in insertion order). A required named member reads as
its declared type (props.label.toUpperCase(), props["label"], props.n += 1), checked at
run time since an index write may store another type; an optional one reads as any (or
unknown) for now.
A literal's value for a required named member, and a value written to one (props.items = [],
props["items"] = []), is checked against its declared type, so { items: [] } holds a
number[] for items: number[] and a callback's parameters get their types
({ onClick: (e) => ... }). A value with more fields than the member's type is converted by
copying them, as anywhere an object type is expected, and printing or serializing the record is
then an error (Node would show the original's fields). A key holding a function is called as a
method (props.onClick(), also on any Record<string, any>).
A spread of a record that may be null or undefined ({ ...props } with
props: Props | null) is a record too, and copies nothing when it is. A spread of a record of
another value type into a record literal ({ ...a, ...b } of a Record<string, string> where a
Record<string, any> is expected), or of a record keyed by string literals into one keyed by
string (Record<"a" | "b", V>), converts each key and value into the new record, in the
spread record's key order; a value that doesn't convert is an error.
An object of a named object type does not convert to an index-signature type (TypeScript allows
it for type aliases but not interfaces): build a record ({ ...o } with values of one type)
instead.
Each is an error: named members next to an index signature of other values
({ name?: string; [key: string]: string | undefined }, which needs undefined), a key
of Velt's integer types (i64) or a symbol key, two index signatures in one type, and an interface with an index
signature that also has methods or extends. As for readonly T[], a readonly index
signature is not checked for writes yet.
interface Scores {
[player: string]: number;
}
const s: Scores = { bob: 2 };
s.alice = 3;
s["carol"] = (s["carol"] ?? 0) + 1;
const r: Record<string, number> = s; // the same object
console.log(Object.keys(r), s.dave ?? "none"); // [ 'bob', 'alice', 'carol' ] noneObject built-ins
JavaScript's Object functions that work on Velt's objects behave as in Node. Each one is
compiled against the static types of its arguments; what a fixed object layout cannot do as Node
does is an error saying it is not supported yet, never a quiet difference.
Object.is(a, b)is JavaScript's SameValue:===, except thatNaNis itself and0is not-0, in generic code too (Object.is(oldValue, newValue)withTa number compares numbers that way). Values of unrelated types are never the same.Array.isArray(x)tests for the array members ofx's type and narrowsxliketypeof(Array.isArray(x) ? x.length : xon anumber | number[]); a tuple is an array. Ananyorunknownvalue is tested at run time; a type parameter's value is not supported yet. On aJsonValueit isx.isArray().Object.assign(target, ...sources)writes each source's own fields totarget, in order (through its setters, as JavaScript's assignment does; an absent optional field is not copied, and one holdingundefinedwhere the target can't hold it is aTypeError) and returnstargetitself, copying enumerable symbol-keyed fields too (a branddefinePropertymade withenumerable: true, below). ARecordtarget takes any keys; an object, interface or class target only fields its type declares (adding one is not supported yet).Object.assign(fn, { … })onto a function makes a function with properties. Assigning from a value that may hold another class is not supported yet. A record source that may benullorundefined(Record<string, V> | null) is skipped when it is, as in JavaScript.Object.freeze({ ... })of a new object literal returns it with every field readonly, so writing one is a compile error, as in TypeScript. Node throws aTypeErrorwhen a frozen object's field is written through any reference, so a frozen object converts only to types whose fields are all readonly (Readonly<T>); passing it where a field could be written is not supported yet, and so is freezing anything but a new literal.Object.fromEntries(pairs)builds aRecord<string, V>from[key, value]pairs: an array, aMapor any iterable.Object.hasOwn(o, k)andObject.prototype.hasOwnProperty.call(o, k)testo's own keys: a record's entries, or the keysObject.keys(o)lists (methods and accessors are on the prototype, so they are not own keys).Object.prototype.toString.call(v)gives"[object Array]","[object Map]","[object Null]"and so on, for each typevmay have.Object.getPrototypeOf(v)returns anObjectPrototype, which===compares withObject.prototype,C.prototypefor a classC,Array.prototype,Map.prototype,Set.prototype,WeakMap.prototype,WeakSet.prototype,Promise.prototypeandnull. A value of a class with subclasses gives the prototype of the class it holds. Prototypes print, convert to strings and to JSON as in Node; reading their members (Object.prototype.toStringas a value) is not supported yet, and neither are the prototypes of other built-in values (Date.prototype,String.prototype) or of classes extending them.Object.create(null)makes an emptyRecord<string, V>without a prototype, where one is expected (const d: Record<string, number> = Object.create(null)):"toString" in disfalse,Object.getPrototypeOf(d)isnull, and it prints as[Object: null prototype] {}. Where ananyis expected (const slots: any = Object.create(null)), it is aRecord<string, any>; a function stored into it is called as its member through theany(slots.default = () => [];thenslots.default()), as is one in any record held by anany, and calling a key that holds no function stops the program with Node'sTypeError: slots.k is not a function.{ ...d }copies it into an ordinary object.Object.createwith another prototype, andstructuredCloneof a record in a program that callsObject.create(null), are not supported yet.
The other Object functions (seal, isFrozen, setPrototypeOf, ...) are not supported yet.
typeof of a global the program doesn't declare is what Node 22 gives in an ES module:
"undefined" for the browser's globals (window, document, HTMLElement, ...), other
runtimes' (Deno, Bun) and CommonJS's (require, module, exports, __dirname,
__filename), "object" or "function" for Node's (globalThis, global, navigator, URL, Intl,
BigInt, AbortController, Headers, atob, ...), and "function" for
a class's or a function's name, so typeof window === "undefined" tells the server from the
browser. Reading or calling one of the absent globals throws Node's ReferenceError (window is not defined), so code that only runs elsewhere still compiles.
class Point {
x = 0;
y = 0;
}
function describe(v: number | number[]): string {
return Array.isArray(v) ? `${v.length} numbers` : `${v}`;
}
const p = Object.assign(new Point(), { x: 3 });
const inert = Object.freeze({ state: "idle" });
const counts: Record<string, number> = Object.create(null);
counts.a = 1;
console.log(Object.is(NaN, NaN), Object.is(0, -0)); // true false
console.log(describe([1, 2]), describe(5)); // 2 numbers 5
console.log(p, inert.state); // Point { x: 3, y: 0 } idle
console.log(Object.getPrototypeOf(p) === Point.prototype); // true
console.log(Object.fromEntries([["a", 1], ["b", 2]])); // { a: 1, b: 2 }
console.log(counts, "toString" in counts); // [Object: null prototype] { a: 1 } false
console.log(Object.prototype.toString.call([1])); // [object Array]Brands with Object.defineProperty
Object.defineProperty(o, KEY, { value }) with a symbol constant KEY adds a brand: the
compiler adds the optional field [KEY]?: T to the interface or object type o is declared
with (const cell: Cell = { ... }, { ... } as Cell, or a parameter cell: Cell; T is the
type of the literal value), so KEY in v tests any Cell for it, as Node finds the property on
the objects that have it.
Unless the descriptor says enumerable: true, it is hidden from console.log and spreads, as a
non-enumerable property is; a property with that symbol written directly anywhere
({ [KEY]: v }, o[KEY] = v) is an error, since it would be enumerable, or written where Node
throws, unless the descriptors say enumerable: true, writable: true.
Defining a brand again with another value throws Node's
TypeError: Cannot redefine property: Symbol(k) (values compared as Object.is does) unless
the descriptors say writable: true or configurable: true; Velt keeps these attributes per
symbol, so every call with one key gives the same ones.
With a string key naming a declared field and only a value, it assigns the field. The object
is written in place and is the result (Object.defineProperty(o, ...) === o).
A brand on a class instance (whose declared field is an own property from construction), other
attributes and keys a type doesn't declare, and Object.defineProperties.
Accessors with Object.defineProperty
Object.defineProperty(o, "k", { get, set }), as a statement, on a const o: I = { … } of the
same function whose literal has the accessor get k() { … } (and set k(v) { … } for a
setter), with I an interface that declares it (object literals implementing
interfaces), replaces the literal's getter or setter,
as in Node: one the descriptor leaves out stays, and after configurable: false defining it
again throws TypeError: Cannot redefine property: k.
A shorthand descriptor ({ get }) is { get: get }. enumerable changes nothing Velt shows,
since an interface value is not listed by key. On a const o = { get k(): T { … } } written
without a type, the getter's result type must be written; a setter the descriptor gives is added
to the literal.
As in TypeScript, o.k = v through the literal's own type stays an error (k is a getter
there); the setter is reached through a type that declares it (o as WritableBox), and a write
before the call has run throws Node's
TypeError: Cannot set property k of #<Object> which has only a getter.
Accessors defined on other objects (a parameter, a class instance, a loop variable named like
the literal), in a module that binds the name Object anywhere (its Object.defineProperty may
be its own), with this inside them, that may throw, that are undefined
(the property would read undefined), and a function not written in the call where the property
may already be defined with configurable: false (Node compares the functions).
interface Counter {
get n(): number;
set n(v: number);
}
function counter(log: boolean): Counter {
let n = 0;
const c: Counter = {
get n() {
return n;
},
set n(v) {
n = v;
},
};
if (log) {
Object.defineProperty(c, "n", {
set(v: number) {
console.log("n =", v);
n = v;
},
});
}
return c;
}
const c = counter(true);
c.n = 2; // n = 2
console.log(c.n); // 2globalThis
globalThis is a bag of typed properties, as libraries use it to share one value between
modules or copies of themselves: (globalThis as { K?: T }).K reads and writes a variable for
the whole process, which starts out undefined, as an unset property of Node's globalThis
does. The key may also be a string constant ((globalThis as Seam)[KEY]), the object a constant
that stands for globalThis (const g = globalThis as Seam;), and
Object.defineProperty(globalThis, "K", { value, writable: true, configurable: true })
assigns it. A Node global Velt has is that global (globalThis.console.log(…) is
console.log(…)).
- A string constant key is a constant declared without a type, or with its own string as the
type, and not cast to
string(satisfies stringkeeps it). Read before its declaration it is TypeScript's TS2448, in the function declaring it and at module level; a closure or function reading it from before its declaration in the same function is an error, as for any constant there. A closure at module level that runs before the declaration reads the constant instead of throwing Node'sReferenceError; an instance field initializer of a module-level class runs when an instance is made, so it may read a constant declared after the class, and an instance made before the declaration reads the value instead of throwing Node'sReferenceError. - The property's type is
T["K"] | undefinedwithTthe asserted type; the assertions of one key must all give it the same type, and a site without one (globalThis.K, or assertedany) takes the type the others give. Where no site asserts one, adeclare global { var K: T }in any module of the program gives itT(withundefined, as it starts unset), as it does for tsc;letandconstthere are not properties ofglobalThis(TypeScript's TS2339), and a.d.tsfile'sdeclare globalis not read for this yet. A property without a type is an error, unless the program only defines it withObject.definePropertyand never reads or assigns it: nothing can see it then, and the call only evaluates itsvalue. - When the asserted type lists its keys (an object type, or a mapped type over a union of
string literals such as
{ [K in StampKey]?: Stamp }, also through type aliases of the module), the key may be a variable:(globalThis as Host)[key]reads the propertykeyholds, the statementhost[key] = v(orhost[key] ??= v,host[key] += v,host[key]!++, …) assigns it, andObject.defineProperty(host, key, { value, … })defines it. The key's type must be one of the listed keys (key: StampKey,keyof Host): a wider one, such asstring, is an error, as in TypeScript (TS7053), also fordefineProperty, which TypeScript allows (Velt's message names no TypeScript error there).deleteof a property ofglobalThisis not supported yet.
interface Hook {
events: string[];
}
type Seam = { __HOOK__?: Hook };
function emit(e: string): void {
(globalThis as Seam).__HOOK__?.events.push(e);
}
function install(): void {
const g = globalThis as Seam;
g.__HOOK__ ??= { events: [] };
}
emit("lost");
install();
emit("kept");
console.log((globalThis as Seam).__HOOK__?.events); // [ 'kept' ]type StampKey = "__A__" | "__B__";
type StampHost = { [K in StampKey]?: { version: string } };
function stamp(key: StampKey, version: string): void {
const host = globalThis as StampHost;
if (host[key] === undefined) {
Object.defineProperty(host, key, { value: { version }, writable: true, configurable: true });
}
}
stamp("__A__", "1.0");
stamp("__A__", "2.0");
console.log((globalThis as StampHost).__A__?.version, (globalThis as StampHost).__B__); // 1.0 undefinedNot supported yet: globalThis as a value of its own (console.log(globalThis),
Object.keys(globalThis)), symbol keys, other keys known only at run time (and using the value
of an assignment through a variable key, f(host[key] = v)), defineProperty on it
without a value, neither writable nor configurable, without writable: true on a property
the program also assigns (Node throws there), or with other writable and configurable than
another call with the same key (an attribute a call leaves out is false on the property it
makes, so whether Node throws depends on which runs first), and using the result of
defineProperty on it.
enumerable changes nothing Velt shows, since the keys of globalThis are not listed.
The in operator
"key" in o tests whether the object o has the member key, as in JavaScript. Velt objects
have fixed fields, so a literal key is answered from o's type while compiling: a field, method
or accessor its object type, class (or a base class) or interface declares is always there, and
one it doesn't declare is not. A setter makes its name a member ("w" in o for set w(v));
static members are the class's, not its instances', and a #private name is never a string key
("#p" in o is false). An optional field of an object type or interface (b?: T) is tested
at run time: it is there once the object has it, also holding undefined or null
("b" in { b: undefined } is true, as in Node). A class's optional field is always there
(TypeScript-compiled classes define it). A value whose class is known only at run time is
tested for it: a Base holding a Sub has the members Sub declares, and an interface value
has those of the class it holds, an optional field the interface declares included. The members every object inherits from Object.prototype
("toString", "constructor", "hasOwnProperty", …) are there on every object, as in Node. A
Record<string, V> is a dictionary, so its keys are tested at run time, also a key known only
then (k in r): a key delete removed is gone, and a number key is its string (1 in r tests
"1"). Such a key may have any type that is a string or a number: string, a union of string
literals ("a" | "b", a keyof type), string | number, or an enum (a string enum's key is its
value, a numeric enum's its value as a string). A closed record
(Record<"x" | "y", V>) has exactly its keys. On a union, in answers for the member the value
holds, and in a condition it narrows the union (union types).
A key known only at run time on anything but a Record<string, V> is an error (`in` with a key known only at run time is not supported on `T`): there is no table of names to look in.
So is a key Velt cannot answer while compiling: a member declared by a struct an interface value
may hold (structs carry no class to tell them apart), and a string key on a built-in or standard
library value (an array, a Map). A value converted to a narrower object type by copying its
fields (const p: P = full, see intersection types) is an error when the program
tests it for a key the copy left out, since Node would find it on the original. An optional
field keeps "absent" apart from a present undefined, also one of a dynamic type (b?: any,
b?: unknown): "b" in { b: undefined } is true and "b" in {} is false, and
console.log, Object.keys, spreads and for...in list such a key while it is present, as in
Node (JSON.stringify leaves out a field holding undefined either way). A primitive
right operand is an error (TypeScript's TS2322; JavaScript throws a TypeError), as is a
possibly null one (TS18047):
the right operand of `in` must be an object, found `{ k: f64 } | null` . So is a left operand that is not a string, a number or a
symbol (TS2322). Symbol keys are described under Symbols, and #x in o under
private names.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number; label?: string };
function area(s: Circle | Square): number {
return "radius" in s ? 3 * s.radius * s.radius : s.side * s.side;
}
class Base {
x = 1;
}
class Sub extends Base {
y = 2;
}
const sq: Square = { kind: "square", side: 2 };
console.log(area(sq), "label" in sq, "toString" in sq); // 4 false true
const seen: Record<string, number> = { a: 1 };
const k: string = "a";
delete seen[k];
console.log(k in seen); // false
const b: Base = new Sub();
console.log("y" in b, 1 in seen); // true falseIterable object literals
An object literal whose one member is a [Symbol.iterator]() method is an Iterable<T>:
for...of, spread, destructuring, Array.from and Iterable<T> parameters take it, and each
of them calls the method again.
function range(from: i64, to: i64): Iterable<i64> {
return {
*[Symbol.iterator](): Generator<i64> {
for (let v = from; v <= to; v++) {
yield v;
}
},
};
}
console.log([...range(1, 3)]); // [ 1, 2, 3 ]- The method is a generator (
*[Symbol.iterator](): Generator<T>), or returns an iterator ([Symbol.iterator](): Iterator<T> { return new Countdown(3); });async *[Symbol.asyncIterator](): AsyncGenerator<T>makes anAsyncIterable<T>forfor await. The method uses the variables around it, as a generator function expression does. - Object literals are plain data in Velt, so this method holds the iterable's whole state. The
literal can have no other members, and
thisin the method is an error (in TS it is the object): use variables, or declare a class thatimplements Iterable<T>and reads its fields. Other generator methods in object literals are errors too: write a property holding a generator function. - Where an interface with methods is expected, the iterator method is one more member of a
literal implementing it, next to the others, and
thisin it is the object: anIterableIterator<T>written as{ next() { … }, [Symbol.iterator]() { return this; } }is the iterator, so a loop over it goes on from wherenext()left off, as in Node. - The value is an object of std's that holds the method and implements
Iterable<T, E>(AsyncIterable<T, E>); annotate it asIterable<T>.
Object literals implementing interfaces
Where an interface with methods is expected (an annotation, a parameter, a return type or
satisfies), an object literal implements it, as a class that declares implements does:
- A method is a function value:
m(x) { … },m: (x) => …orasync m(x) { … }. Its parameter types may be left out: they come from the interface's method. - A field is a value, or a getter
get f() { … }, which runs on each read (Getters and setters). - A method with a default body and an optional field may be left out (the field is
undefined); every other member must be given, and a member the interface doesn't declare is an error. - The value is an interface value: its members are read and called through the interface.
- Such a literal may be a module constant, exported or not (
export const upper: Handler = { … }). Like every module constant it is evaluated at each use, and each use passed on is a new value, as for a function-valued constant (Variables), unless the program compares it by identity (===): it is then module state, one value as in Node.
interface Counter {
next(step: number): number;
readonly count: number;
}
function counter(): Counter {
let n = 0;
return {
next(step) {
n += step;
return n;
},
get count() {
return n;
},
};
}
const c = counter();
c.next(2);
c.next(3);
console.log(c.count); // 5- The methods use the variables around them, as arrows do, and
this, the interface value they are called on (thisin object-literal methods). - A generic interface method (
map<R>(f: (x: T) => R): R) cannot be a function value: declare a class that implements the interface. - A method that throws must be allowed to by the interface method's function type, as for any
function value: an optional method (
m?(x: T): R) is a field of function type, which doesn't allow throwing unless it saysthrows. console.logandJSON.stringifyof such a value are not supported yet (as for other interface values).
Getters and setters
A field is a value, or a getter get f() { … }, which runs on each read. A getter method of
the interface (get f(): T) takes a getter or a value (f: 5), and a setter method
(set f(v: T)) a setter set f(v) { … }, which runs on each x.f = v; a value given for a
get f()/set f(v) pair ({ f: 1 }) is stored, and x.f = v writes it. A class implements
such a pair with a field of the same name and type, too (class C implements T { f = 3 }).
A field of the interface can't be written through the interface value yet (x.f = v is an
error), so a setter given for a field never runs: declare a setter method in the interface
instead.
A type alias of an object type with accessors (type Sized = { get size(): number }) is such an
interface. An object type written in place (x: { get size(): number }) reads a getter as a
readonly field (and a setter as a field): it takes plain values ({ size: 2 }) but not yet an
object literal with accessors or a class instance.
Such an alias is an interface, not an object type: a value of an object type with the same
members converts to it (const t: T = p with p: { v: number }, or an interface
I { v: number }, or a wider object type, the same object, read and written through it). When
its accessors come in pairs of one type (get v(): number with set v(x: number), or a getter
alone), an object literal of values for it ({ v: 1 }) is such an object, and a value of it
converts back to that object type (const q: { v: number } = t, also a readonly or field-only
interface form of it) as the object it holds.
That conversion needs every value of the alias in the program to hold such an object: a class
implementing it, or a literal with accessors for it, makes it an error, also when the value
reaches the alias through another interface of the same members. JSON.stringify, object spread
({ ...t }), keyof T and Partial<T> do not take the alias yet.
this in object-literal methods
In a method, getter or setter of an object literal, this is the object the method is called
on, as in TypeScript, where Velt knows the literal's type:
- where an interface is expected (above),
thisis the interface value: its members are read, written and called through the interface; - in a literal with no expected type (
const o = { … }, or one returned from a function without a result type),thishas the literal's own type, which TypeScript shows as{ count: number; inc(): void }. Such a literal, and one with getters or setters, is a value of an interface Velt makes from its members. So is a literal with getters or setters passed where a type parameter is inferred (mark({ get value() { … } })withmark<T>(x: T): T), and one stored as anany,unknownorobjectis converted to it from there; one where an object type is expected that declares only fields ({ value: T }, sigx'stoSignal) is a view of the literal in a program that makes proxies (views), asobj as Computed<T>is. Passed for a type parameter whose bound declares fields (T extends { v: number }), the literal satisfies the bound, its getters filling the fields, andTis the literal's own type; writing a field it has only a getter for is aTypeError. Each read runs the getter at that moment and each write runs the setter. As text (`${o}`,String(o),o + "") it is[object Object], as in Node.console.logandJSON.stringifyshow such a literal as Node does:{ n: 10, double: [Getter], only: [Setter] }, and the getters' results in JSON (a setter alone is left out).
const counter = {
count: 0,
step: 2,
inc() {
this.count += this.step;
return this;
},
get double() {
return this.count * 2;
},
set double(v) {
this.count = v / 2;
},
};
counter.inc().inc();
console.log(counter.count, counter.double); // 4 8
counter.double = 20;
console.log(counter.count); // 10
interface Plugin {
name: string;
installs: number;
install(app: string[]): void;
}
const router: Plugin = {
name: "router",
installs: 0,
install(app) {
this.installs++;
app.push(this.name);
},
};
const app: string[] = [];
router.install(app);
console.log(app, router.installs); // [ 'router' ] 1- Arrows inside a method see its
this, as in TS; an arrow property of the literal ({ f: () => this.x }) has thethisaround the literal, as it does in TS. A literal nested in a method has its ownthis. Generator and async methods takethistoo, and so does a function expression that is a property's value ({ f: function () { return this.x; } }). - In a literal with no expected type, a method using
thisgives its parameter types (add(n: number) { … }), as TS's--noImplicitAnyrequires. Its result type comes from its body, also when it calls other methods throughthis; a method whose result depends on itself (fact(n: number): number { … this.fact(n - 1) … }) writes it, as TS requires (TS7023). A setter's parameter without a type takes its getter's. - The object is passed to the method by the call (
o.m()), so the object and its methods form no reference cycle. Such a method read as a value (const f = o.m,xs.map(o.m)) is an error in a literal with no expected type: Node would call it without its object (thisundefined, aTypeErroronce it reads a member); call it on its object, or wrap it in an arrow ((x) => o.m(x)). A method of an interface value read as a value stays bound to the object it was read from (methods as values). - A method read off another object as a property's value (
{ n: 2, m: c.m }) is an error when its body may usethis: Node calls it with the literal asthis(o.m()), while the value read keeps the object it was read from. Write a method that calls it (m() { return c.m(); }). A method that doesn't usethismay be such a value. Object.keysandfor…inlist a getter and setter pair once, under its name, as Node does:Object.keys({ n: 1, get d() { … }, set d(v) { … } })is[ 'n', 'd' ].
this in a method of a literal whose expected type is an object type
({ x: number; norm(): number }, whose fields hold plain functions) or any; declare an
interface instead. Calling such a method with another this (.call, .apply, .bind) is
not supported either. Object spread ({ ...o }) of such a value, console.log and
JSON.stringify of it are not supported yet, as for other interface values.
Converting a literal to an interface
A getter fills the slot of an interface field when the literal's value is converted to an
interface with methods (o as V, const v: V = o with
interface V { readonly value: number; show(): string }), as in TypeScript; the getter runs on
each read. Writing the field through the interface calls the literal's setter, which takes the
field's type (a setter taking another type, also a wider union, is an error where the literal
converts); without a setter, writing it is an error (Node throws a TypeError).
A property holding a function, a method that doesn't use this or an arrow
(show: () => "x"), fills the slot of a method with the same parameters and result, unless the
function may throw (an error for now: write the literal where the interface is expected). An
interface with only fields is an object type, which such a value does not convert to yet.
A plain literal, with no getters or setters and no method using this
(const p = { x: 1, show() { return "p"; } }), is an object of its own type, which converts to
an interface with methods once stored too (const q: P = p, p as unknown as P), as the same
object (any and unknown). Its properties holding functions fill the
interface's methods, as in TypeScript, also through any or unknown
(init as unknown as Ctx), and a function's result may be anything a plain assignment to the
method's result type accepts:
interface Ctx {
n: number;
update(k: number): number | null;
}
const c: Ctx = { n: 1, update: (k: number) => k + 1 };
const init: any = { n: 5, update: (k: number) => k * 2 };
const d = init as unknown as Ctx;
console.log(c.update(2), d.update(3), d.n); // 3 6 5bigint
TypeScript's bigint is a type name in Velt, but there are no BigInt values yet (no 10n
literals, no BigInt(); arbitrary-precision integers are BigInt from velt/math).
Until there are, bigint is never: the type no value has. So types written for TypeScript
that mention it work for every value a Velt program holds: a union naming it is its other
members, a conditional type testing it never takes that branch, and a type bound naming it
still chooses overloads by its other members, as TypeScript does for those values.
type Primitive = string | number | boolean | bigint | null;
type Widen<T> = T extends number ? number : T extends bigint ? bigint : T;
function wrap<T extends Primitive>(target: T): { value: T };
function wrap<T extends object>(target: T): T;
function wrap<T>(target: T): { value: T } | T {
return String(target) === "[object Object]" ? target : { value: target };
}
const state = wrap({ st: "idle" }); // the object signature
state.st = "ready";
const n: Widen<5> = 7;
console.log(state.st, n); // ready 7typeof x === "bigint" and case "bigint": in a switch (typeof x) are always false, and a
test the remaining type always passes leaves nothing behind (if (typeof x === "string") return;
on a string | bigint, then x is never). A value given where a bigint is expected is an
error, as in TypeScript; the message names the type never. So is an any or unknown given
there (const b: bigint = a, a as bigint), which TypeScript accepts: no value in a Velt program
is a bigint.
any and unknown
any and unknown hold a value of any type together with its type, like C#'s dynamic. Every
value converts to either. An any converts back to every type, and the value is checked where it
enters typed code: a value of another type stops the program with a TypeError (Node would
keep the wrong value). An unknown must be narrowed first (typeof, instanceof), or
converted with as T, which is checked the same way. Both are the same value at run time, so
a container of one is a container of the other: a Record<string, any> is a
Record<string, unknown> (and back), and so are any[] and unknown[], Map<string, any> and
Map<string, unknown>, Sets, nullables and function types that differ only there. It is the
same object seen through the other type, as in TypeScript: a write through one is seen through
the other, and === compares them as one value. A tuple ([any, number] as
[unknown, number]) converts too, but is copied, as every tuple is when assigned
(Values and references).
class Point {
x: number;
constructor(x: number) {
this.x = x;
}
}
const p = new Point(1);
const a: any = p;
const q: Point = a; // checked: `a` holds a `Point`
console.log(q === p, typeof a); // true object
const n: any = null;
console.log(n == undefined, n === undefined); // true false
const u: unknown = "text";
if (typeof u === "string") {
console.log(u.length); // 4
}What works on any and unknown: conversions in and out (also null, undefined,
T | null, unions and class instances, checked against the class and its subclasses, and an
instance of a generic class against that very instantiation (Box<number>, so a
Record<string, number> converts only from a record of that type: an object of a fixed shape
stops with a TypeError for now; Node reads any object as a record); into a
union or a literal type such as "red" | "green" the value takes the first member it is: a
typeof test for number, string, boolean and symbol (k as string | symbol),
instanceof for a class, === for a literal, Array.isArray for the union's one array member;
a value that passes none of them converts to the union's one interface or object type member
when it has one, checked as x as VNode is: VNode | string | number from an any),
typeof (narrowing to number, string and boolean), a number of any numeric type into
any other (const a: any = 5 as i64; a as number, a as u8: there is one JavaScript number, so
it converts as a number would with as, exactly up to 2^53), instanceof, truthiness,
===, !==, == and != (JavaScript's loose equality: null == undefined, "1" == 1) on any
operand whose type is any or unknown (a variable, a field, an element, a call), and
console.log. An object is itself in an any whatever its static type was when it was
converted (a class instance through its base class, an interface value, a function converted
twice), and one object converted twice is one value (u1 === u2, one Set<unknown> entry). As in TypeScript, a union with any is any (any | null, any | undefined) and one
with unknown is unknown: the null or undefined is held by the value itself, so
m.get(k) of a Map<string, any>, r[k] of a Record<string, any> and find on an any[]
give an any that is undefined when nothing is there, and null when a null was stored;
a left-out optional field q?: any holds undefined. A value whose type is a type parameter
converts to any and unknown too (const a: any = t with t: T, t as unknown as X): the
value of the instantiation goes in as above, so a T that is number | null holding null
gives the dynamic null, a union the member it holds, a literal type its value and an enum
its number or string. An enum value is its number or string
in an any (typeof is "number" or "string"), and converts back to the member with that
value. Generic code compares an any as === does (indexOf, includes, a generic
a === b), and Set<any> and Map<any, V> keys compare by value as in JavaScript (NaN is
itself, -0 is 0), objects by identity.
Differences from Node: null and undefined converted to T | null both give null
(a T | null has no undefined; convert to T | undefined to keep it); == between an
object and a boolean, number or string stops with a TypeError (JavaScript converts the object
with ToPrimitive: [] == 0 is true); an instance of a subclass of a generic class does not
convert to that class (Sub extends Box<number> to Box<number>: a TypeError); and a
function prints as [Function (anonymous)] (Node prints its name, [Function: f]).
An any or unknown converted to a function type (f as (c: Ctx<any>) => string, also
T | undefined of one) is not checked against the type the function was made with, as Node
calls a function whatever its declared types: the result is a function of the target type that
calls the value through any, converting each argument to the held function's parameter type
and the result to the target's result type, each checked as above. A value that is no function
converts too, and calling the result stops with TypeError: the value converted to a function type is not a function (Node's TypeError: f is not a function; typeof of the result is
"function", where Node gives the held value's). A function of exactly the target type is
that function itself, so ===, indexOf, Set and Map see the one value, as in Node. Only
a function of another type gives a new function, at each conversion, and that one is not ===
the function held (Node keeps the one function). In a union (ViewFn | Promise<ViewFn>), the
one function type member is told from the others by typeof and converted the same way; a
Promise member needs no test, as a promise is never held as any (a function called through
any that returns one stops with TypeError: a promise cannot be held as `any` yet).
interface Ctx<T> {
v: T;
}
const setup: any = (c: Ctx<number>) => "x" + c.v;
const s = setup as (c: Ctx<any>) => string;
console.log(s({ v: 1 })); // x1The same holds for a function that is a member of the value converted: a field of function type
read from a record (Object.create(null)) or an object converted to an object type, and an element
of an array of functions. A function of another type than the member's converts as above,
as TypeScript lets a function with optional parameters stand for one with fewer and any[] for any
array: slots as { default: () => VNode[] } where slots.default holds (scopedProps?: any) => any[] gives a default that calls the held function with no arguments and converts each element
of its result to VNode. Extra arguments are dropped and missing ones are undefined, as in Node,
and a rest parameter of the target type ((...xs: number[]) => string) passes its elements as
separate arguments. This reaches members of objects inside the value ({ inner: { deep: { f } } }),
up to four objects deep, and the results of the converted functions; a function five or more
objects deep stops the conversion with a TypeError. The function is made once, at the conversion, so each
call costs one call through any, and the member read twice is one function (s.f === s.f). Two
conversions of the same object give two functions, though: (o as S).f === (o as S).f is false,
where Node keeps the one function. In a program that makes proxies, the converted object is a view
of the object (below), whose member converts at each read: there each read gives a new function, so
s.f === s.f is false too, and each call of s.f() makes the function first. An async function
of another type than the member's (async (x?: any): Promise<any> as () => Promise<any>)
converts, but calling it stops with TypeError: calling this function through `any` is not supported yet, as an async function is not called through any yet; one of the member's
own type is that function.
interface Slots {
default: () => string[];
}
const store: any = Object.create(null);
store.default = (props?: any): any[] => ["a", props === undefined ? "none" : "some"];
const slots = store as Slots;
console.log(slots.default().join(",")); // a,noneAn object of another type converted to an object type (a as Item where a holds a class
instance with Item's fields, or an object literal with more fields than Item) is, in a program
that makes proxies (new Proxy anywhere in its own code, as a reactive framework does), a view
of that object. Nothing is copied: each field is read from the object when it is read,
and checked then as the field's type, so a field the object lacks stops with a TypeError at
that read (Node reads undefined). A write goes to the object for the fields the object has;
writing an optional field the object lacks (item.note = "hi" on a class instance without
note) stops with the TypeError of a fixed-shape object, where Node adds the property.
An any written to a field of a view (brands.__props = null as any) goes to the object as it
is, without the check as the field's type, as Node stores any value: an object that holds its
properties as any (a function with properties) keeps it, and a read of the field checks it
then; an object of a fixed layout checks it as its own field's type.
Converting the object again while a view of it lives gives that view (an object has one view at
a time, whatever object type it is converted to), so ===, Map, Set and indexOf see one
value. Back in an any (and for WeakMap, WeakSet and WeakRef, in, Object.keys,
for...in and JSON.stringify) the view is the object itself: a WeakMap entry lives as long
as the object, and deref() gives its view again. Destructuring (const { type, props } = vnode, also in parameters and for...of),
spreads ({ ...item }) and keyed reads read the object's fields at that moment. A spread or an
object rest pattern (const { name, ...rest } = item) copies the fields of the view's type only
({ ...item } of a Product viewed as Item has name and price, not stock; Node keeps
every own property), since an object type has a fixed shape; JSON.stringify(item) shows all
of the object's members. A view is copied like the object it views: item.clone() is a view of
a copy of the object, and a view sent to another thread (spawn, a channel, shared(...), a
Mutex's value, a task's result) while the object is still used here is a view of the copy
there (Async), so a write there does not reach the object here, and converting the
object here again gives a view of its own. A union with
one object type member (VNode | string | null) converts an object that is no VNode the same
way. In a program without proxies, an object of another type converted to an object type is a
copy of the object's members, each read and checked as the field's type when the conversion
runs: an optional field the object lacks is absent (const e: G = { name: "e" } as any with
interface G { name: string; tag?: string } reads e.tag as undefined, as in Node), a
literal-typed field (kind: "a") is checked against its literal, and a required field the
object lacks stops with a TypeError; a later write to the copy does not reach the object. Into
a class, an object of another type stays a TypeError.
type Item = { name: string; price: number };
class Product {
name = "pen";
price = 2;
stock = 10;
}
const tracked = new Proxy({ hits: 0 }, {}); // the program makes proxies
const p = new Product();
const a: any = p;
const item = a as Item; // a view of `p`
item.price = 3;
console.log(p.price, item.name, tracked.hits); // 3 pen 0
const back: any = item;
console.log(back === a, item === (a as Item)); // true trueAn object literal converted to an interface with methods (init as unknown as Ctx with
interface Ctx { n: number; update(): void }, in every program) is that object, as in Node:
a property holding a function fills a method, so c.update() calls the object's
update, and a write through the interface (c.n = 5) changes the object. The function may
be an arrow, a function expression or a named function; it may take fewer parameters than
the method (the call passes it only those, as JavaScript drops the extra arguments), and for a
method returning void it may return anything. Its result may be any value a plain assignment
const r: R = f() takes as the method's result type R: the method's type, an optional of
it (() => 1 or () => undefined for get(): number | undefined, () => (k > 0 ? { v: k } : undefined) for opt(): Item | undefined), or an object of its layout, also nested
(() => ({ v }) for make(): Item with interface Item { v: number }, () => ({ a: { v } })
for pair(): { a: Item }). An array of objects of another type (list(): Item[] from
() => [{ v: 1 }]) and an async function whose result type is inferred don't fit yet, as
they don't in a plain assignment; write the result type (async (): Promise<Item> => …). A function expression using this
(function (this: any, by: number) { this.n += by; }) gets the object as this, as in
o.m(); its arguments and result pass through any, so the method's parameters must be
numbers, strings, booleans, objects, arrays, functions or dynamic values, and its result
void, a number, a string, a boolean or a dynamic value (a union, an optional or a literal type
there makes the conversion a TypeError). Each interface field is the object's field of that
name. An object a plain assignment keeps as the field's type (inner: { v: 1 } for
inner: Inner with interface Inner { v: number }, also with objects nested in it) is that
object, so c.inner.v = 2 changes the literal's. One that assignment converts by making a
copy ({ v: 1, deep: { w: 1 } } for an Inner whose deep is an interface Deep, where
Velt's plain assignment copies too) is read as such a copy and written whole; changing a part of
it through the interface (c.inner.v = 2), or a field of a copy read from it
(const i = c.inner; i.v = 2), is an error, since it would change the copy, as such a write
after a plain assignment is. Of another type (props: Record<string, any> for props: PropsAccessor<{}>, a
renderFn: any for renderFn: ViewFn | null), it is converted through any at each read,
and checked then as x as T is (a TypeError if it holds no such value), and a write
converts the value back to the object's field type; changing a part of such a field
(c.props.a = 2) is an error, since it would change a converted copy. A plain conversion without any (const c: Ctx = init) takes fields of the same type only. An
optional interface field (label?: string, also an optional method done?(): string) may be
missing from the object, and then reads undefined, where writing it stops with a
TypeError (an object of a fixed shape gets no new properties; Node adds the property).
One the object holds as a plain value (more: 3 for more?: number) can't hold undefined:
writing undefined to it through the interface (c.more = undefined) stops with a
TypeError (Node stores undefined).
A literal whose methods use this, or with getters, converts as its own interface does
(above). Differences from Node: the conversion checks the
object's members, so a member the interface requires that the object lacks, or a method the
object holds no function for, stops there with a TypeError (Node throws only once the member
is called: c.update is not a function); and an object built in a generic function, of a
type with a type parameter, does not convert yet. The interface may be written with a generic
function's type parameters (o as Box<T> in function view<T>(o: unknown): Box<T>, a
component's setup(ctx as SetupCtx<P>)), or be a type parameter itself (o as T in
conv<T>, called as conv<Box<number>>): each call at other type arguments (view<number>,
view<string>) converts to that instance of the interface, as in Node. A function held
through any (setup as any) and called with such an object converts it to the parameter's
interface the same way.
interface Ctx {
n: number;
label?: string;
update(): void;
add(k: number): number;
}
const init = {
n: 1,
update: () => console.log("update"),
add: function (k: number) {
return k * 3;
},
};
const c = init as unknown as Ctx;
c.update(); // update
console.log(c.add(4), c.label); // 12 undefined
c.n = 5;
console.log(init.n); // 5An object literal with a getter ({ get value() { … } }, the value of its own interface)
asserted to an object type that declares only fields (obj as Computed<T> with
interface Computed<T> { readonly value: T }) is, in a program that makes proxies, a view of
the literal too: each read of value runs the getter at that moment, and a write runs
its setter (one written in the literal or added by Object.defineProperty), as Node's one
object does. Each field of the type must be a field or a getter of the literal, of a type that
converts to or from the field's. In a program without proxies such a cast is an error, since a
copy would keep the getter's value once.
An array of another element type converted to an array type is a view of that one array, in
every program: an any holding an array converted to T[] (kids as JSXChild[] with
kids holding ["a", 1], also as a union's array member: kids as JSXChild), and a cast of an
existing array to another, comparable element type (items as VNode[] with items: JSXElement[], result as JSXChildren), which sema writes as the conversion through any.
Nothing is copied up front: each access sees the original's elements at that moment, each
converted to T with the check above (an object element of another type is a view of it, in a
program with views of objects), and a change through the view (an index assignment, push,
pop, reverse, splice, ...) is written back to the original at once, converted to the
original's element type, so a value the original's element type cannot hold (a string written
into a view of a VNode[]) stops with a TypeError where Node stores it. Back in an any the
view is the original array (===, Array.isArray, WeakMap keys). An array has one view of
each element type at a time, so two conversions of one array to T[] are one value, as in
Node: ===, indexOf, includes, and Set and Map keys see one array. A view sent to
another thread or clone()d is a plain array of its elements (a copy, as any array sent while
it is still used here). An access brings only what it touches up to date: length is the
original's, an index, a for...of step or a callback method's step (forEach, map, ...)
converts that element (and, the first time, the ones before it), and a write of an element, a
push, pop or swap writes back only what changed; operations on the whole array (slice,
spread, sort, JSON.stringify, clone()) bring every element up to date. So a loop over a
view costs a plain loop plus a conversion of each element it reaches, and the elements after
the last one read are never checked, as Node never checks them. sort(cmp) sorts a copy of
the elements stably and writes them back. An object view whose field is an array of another
element type ((raw as any) as View with raw.items: string[] and View.items: Child[])
shares that array as an array view. Array types that no conversion makes a view keep their
layout and code.
type VNode = { type: string };
type JSXChild = VNode | string | number | boolean | null | undefined | JSXChild[];
const kids: any = ["a", 1];
const view = kids as JSXChild[]; // a view of the array `kids` holds
view.push("b");
console.log(kids.length, view[2]); // 3 b
const back: any = view;
console.log(back === kids); // trueMembers of an any are read and called as in JavaScript: x.f, x["f"], x[k] with a
string, number or any key (any expression: x[key()], x[i + 1]), x.m(a, b) (a method,
or a field holding a function, typed or any) and x(a, b) when x holds a function. The member
is found at run time in the value the any holds: a field, getter or method of a class
instance (a subclass's too when it was converted through its base class), an object type's
field, length and elements of an array, length of a string, and the members of the standard
library's classes (size, get, set and has of a Map). The result is an any; each
argument is converted to the parameter's type with the same check as above, a left-out one is
undefined, and extra ones are ignored. A spread argument (f(...args), o.m(a, ...rest))
passes the elements of an array, each converted the same way.
class Counter {
n: number = 0;
get double(): number {
return this.n * 2;
}
add(k: number): number {
this.n += k;
return this.n;
}
}
const c: any = new Counter();
console.log(c.add(5), c.double, c.missing); // 5 10 undefined
const m: any = new Map<string, number>();
m.set("a", 1);
console.log(m.get("a"), m.size); // 1 1
const xs: any = [10, 20];
console.log(xs.length, xs[1], xs[2]); // 2 20 undefined
const f: any = (s: string) => s.toUpperCase();
console.log(f("hi")); // HIA member the value's fixed shape does not have reads as undefined, as in Node, and so does
a property an array, a string, a number or a boolean does not have (xs.type, "s".kind:
not length, an element or a member of its prototype). A class's
private members are members through any too (TypeScript checks private, Node doesn't),
except a standard-library class's, which implement it and which Node's built-ins don't have
(re.matchAt(…) on a RegExp is TypeError: re.matchAt is not a function). A null or
undefined receiver stops with Node's TypeError: Cannot read properties of undefined (reading 'f') before the call's arguments are evaluated, calling something that is not a function with
TypeError: x.f is not a function, and an argument of the wrong type with the conversion's
TypeError. Optional chaining short-circuits as in Node: x?.f, x?.m(), x?.[0] and
x.f?.() are undefined when the link is null or undefined, and the rest of the chain
(x?.a.b) is not evaluated. What Velt cannot do through any yet
stops with a TypeError saying it is not supported yet, rather than giving a wrong value: a method read without a
call, string indexing, generic methods, and members whose type is an enum. A function or method
whose type says it may throw is called as any other, and returns its value when it doesn't
throw; an error it throws through any can't be caught yet: it ends the process with
Node's Uncaught … report and exit code 1, what Node does when nothing catches it, but without
running the finally blocks around the call first, which Node does. An async
function held by an any can't be called through it yet (its promise has no any form).
The built-in methods of a string, an array, a number or a boolean held are called as in
JavaScript (see "Operators and built-in methods on any" below). A member of a literal type (kind: "ok"), a union or an
interface reads as the value it holds.
Members of an any are written as in JavaScript too: x.f = v, x["f"] = v and x[k] = v
with a string, number or any key. The value is converted to the member's type with the
same check as above (a value of another type stops with its TypeError, and the member keeps
its old value) and stored in the object itself, so every other reference to it sees the write;
a setter is called with it, also an object literal's (set value(v) written in it or added by
Object.defineProperty; a read runs its getter), and Reflect.set(o, "value", v) does the
same. Such a literal held by an any prints its accessors as Node does (value: [Getter],
[Setter], [Getter/Setter]), JSON.stringify writes what its getters return, and
Object.keys and for...in list each property once. Writing a property that has only a getter
stops with Node's TypeError: Cannot set property value of #<Object> which has only a getter.
Known differences: these stops can't be caught (as for the other failed writes through
any), Reflect.set stops the same way where Node returns false, and an error the
accessor itself throws ends the process as uncaught when the accessor runs through any or a
view (as a trap's error does through a typed proxy).
A Record<string, V> takes any key, adding the ones it lacks, as
in Node; an array takes its elements inside its length, an element at its length (it grows by
one, as a[a.length] = v does in Node) and a shorter length, which drops the elements past it
(a length that is not a whole number from 0 up is Node's RangeError: Invalid array length). The
other values have a fixed shape:
adding a property stops with TypeError: Cannot add property k: an object of a fixed shape gets no new properties through any (#924) (Node adds it), writing a getter that has no setter with
Node's TypeError: Cannot set property k of #<C> which has only a getter (what strict code
gets; in sloppy code Node ignores the write), and a null or undefined receiver with Node's
TypeError: Cannot set properties of undefined (setting 'k'), after the value is evaluated.
A function is an object, as in JavaScript: it takes any property (factory.__setup = setup),
which every reference to the function sees, and reads, calls, in, Object.keys and delete
through any find it (see Functions with properties).
Writing a readonly field, a method, an element further past the end of an array or a longer
length (Node leaves holes, which Velt's arrays don't have), a function's name or length,
or a member of a string or number stops with a TypeError saying it is not supported yet. x.f ??= v, x.f ||= v and
x.f &&= v read the member once and evaluate and write v only when the operator calls for it,
as in JavaScript; the expression is the member read or the value written. A new C() without
type arguments there takes them from what the expression is assigned to, as nothing else gives
them: const seen: Set<string> = (ctx.keys ??= new Set()); stores a Set<string>. The other compound
assignments (x.n += 1, x[i] *= 2) and x.n++ read the member once, combine it as the
operator does (below) and write the result back.
Array.isArray(x), x instanceof C for a generic class C (TypeScript's C<any>, including
Map and Set) and typeof x === "function" narrow an unknown to an any (and leave an
any one): the value stays as it is, and its members are read, called and written as above.
typeof tests for number, string and boolean and instanceof of a class without type
parameters narrow to that type itself, so member access after them is typed and direct. An
arrow passed to a method of an unknown or object narrowed by Array.isArray or by
instanceof of a generic class takes any parameters without annotations, as the methods of
TypeScript's any[] and Map<any, any> give them (raw.forEach((v, k) => …)), also on the
result of an array method returning an array (u.filter((x) => …).map((x) => …)); on a variable
declared any, TypeScript asks for them to be written, and so does Velt. forEach of a Map
or Set (or of a class extending one) called through any passes JavaScript's arguments:
value, key (a Set's value again) and the collection.
class Box<T> {
v: T;
constructor(v: T) {
this.v = v;
}
}
function describe(u: unknown): string {
if (Array.isArray(u)) {
return `${u.length} items, first ${u[0]}`;
}
if (u instanceof Box) {
return `a box of ${u.v}`;
}
return "something else";
}
console.log(describe([3, 4])); // 2 items, first 3
console.log(describe(new Box("s"))); // a box of s
const p: any = { x: 1, y: 2 };
p.x = 10;
p["y"] = 20;
console.log(p.x, p.y); // 10 20
const r: any = { a: 1 } as Record<string, number>;
r.b = 2;
console.log(r.b); // 2<, >, <= and >= with an any operand compare as JavaScript does: two strings by their
UTF-16 code units, any other pair as numbers, each converted with ToNumber (true is 1,
null is 0, undefined and a string that is not a number are NaN, and every comparison with
NaN is false). Comparing a symbol stops with Node's TypeError: Cannot convert a Symbol value to a number; an object is converted first, as JavaScript's ToPrimitive does: a Date is its
time, another object the text its valueOf() or toString() gives ([2] < 10 is true,
[2] < "10" compares strings, {} is NaN against a number).
const n: any = 5;
const s: any = "10";
const u: any = undefined;
console.log(n < 7, s > 9, s < "9", n < u, n >= u); // true true true false falseString(x), a template literal and a string + / += write an any, unknown or object
by the value held, as JavaScript's ToString(ToPrimitive(x)) does, not as console.log prints
it. String(x) and templates ask a class instance for its [Symbol.toPrimitive]("string"),
else its toString() (its own or a base class's, an Error's), then its valueOf() when that
returns an object; + asks for [Symbol.toPrimitive]("default"), else its valueOf(), then
its toString() when that returns an object (each result tested when the program runs,
whatever its declared type). An object literal is written by its own toString and valueOf
members in the same order; another object as Object.prototype.toString does
([object Object], [object Map]). When every method returns an object, the program stops
with Node's TypeError: Cannot convert object to primitive value, and when one returns a
symbol with TypeError: Cannot convert a Symbol value to a string, reporting the line of the
String, template, + or join. An array (also one held in a typed any[], unknown[], object[] or a tuple
of them, and its join() and toString(), also xs.join(sep) of an any xs holding one) is
written as Array.prototype.join does: each
element as String(x) writes it, null and undefined elements and an array holding itself
as empty text ([1, [2, 3], null] is 1,2,3,). In a template or a +, a symbol stops with
Node's TypeError: Cannot convert a Symbol value to a string (only String(s) writes it, as
Symbol(desc)); a function stops with a TypeError saying it is not supported yet (Node writes its source text),
and so does a proxy (Node reads its toString through the get trap). So is a value whose
type is a type parameter instantiated with any, unknown or object, or an array or tuple
of them (String(v) or `${v}` in function show<T>(v: T) called as show<any>({ a: 1 })
is [object Object], as show<unknown[]>([{ a: 1 }]); a symbol in a template or a + stops
with the TypeError); the function's other instantiations write their own types' text.
class Celsius {
valueOf(): number {
return 21;
}
toString(): string {
return "21 °C";
}
}
const t: any = new Celsius();
console.log(`${t}`, "" + t); // 21 °C 21
const xs: any[] = [[1, 2], { a: 1 }, null];
console.log(String(xs), xs.join(" | ")); // 1,2,[object Object], 1,2 | [object Object] |Object.is(a, b) with an any or unknown operand is SameValue, as in JavaScript: ===,
except that NaN is itself and 0 is not -0. Number.isFinite(x), Number.isNaN(x),
Number.isInteger(x) and Number.isSafeInteger(x) take an any or unknown as TypeScript's do,
and are false unless x holds a number.
for (const x of v) over an any holding an array visits its elements as anys, reading
the length before each step as JavaScript's array iterator does. Another value (a string, a
Map, an iterable object) stops with a TypeError saying it is not supported yet.
Object.keys(x) of an any, an object or a Function lists the own enumerable string keys
of the value held, in JavaScript's order (integer-like keys first, ascending, then the others in
order): the fields of a class instance (a subclass's too) or object (an optional field only while
present), a Record's keys, an array's or a string's indexes as strings, and none for a number,
a boolean, a function, a Map or a Set. null and undefined stop with Node's TypeError: Cannot convert undefined or null to object; a proxy runs its traps ("Proxy and Reflect"
below). Object.keys of an unknown is an error until it is narrowed, as in TypeScript. A spread
of an any or object into an object literal ({ ...a, b: 1 }) builds a Record<string, any>
with those keys and their values (null and undefined spread nothing):
const a: any = { x: 1, y: "two" };
console.log(Object.keys(a)); // [ 'x', 'y' ]
const merged = { ...a, z: true };
console.log(merged); // { x: 1, y: 'two', z: true }Object.hasOwn(x, k) and Object.prototype.hasOwnProperty.call(x, k) of an any or object
test the keys Object.keys(x) lists (and an array's or string's length); the key may be a
string | symbol (a proxy trap's prop), and a symbol is not among those keys.
Object.getPrototypeOf(x) gives the prototype of the class of the value held (C.prototype),
Object.prototype for an object or a record (null for one made by Object.create(null)) and
Array.prototype for an array, so proto === Object.prototype || proto === null tells a plain
object apart; a value whose prototype Velt doesn't name (a number, a function, a Date) stops with
a TypeError saying it is not supported yet. Object.prototype.toString.call(x) gives the [object Tag] of the
value held. k in Object.prototype with a string known only at run time tests the members every
object inherits (toString, constructor, ...).
JSON.stringify(x) of an any, unknown or object (also inside a typed value, such as an
unknown[] or a Record<string, any>) writes the value held as Node does, with a class's
toJSON() and an interface value written as the object it holds: undefined, functions and symbols are left out as object members and written as
null in arrays, and an object that holds itself stops with TypeError: Converting circular structure to JSON. Node returns undefined for such a value at the top; a Velt string cannot
hold it, so JSON.stringify of an any holding undefined, a function or a symbol stops with a
TypeError saying it is not supported yet. A value with no JSON form here (an object with a function field, a
proxy) also stops with a TypeError.
An object literal converted to any or unknown (const u: unknown = { a: null }, also
{ b: { c: null } } and { a: [null, 1] }) gives a null field TypeScript's type null: it
holds an any that is null. A void value or one of type undefined (a call of a function
returning it) converts to an any holding undefined.
Operators and built-in methods on any
The arithmetic, bitwise and unary operators take an any operand as JavaScript does, as
TypeScript accepts them: -, *, /, %, **, |, &, ^, <<, >>, >>>, unary -,
+ and ~ convert each operand with ToNumber ("3" is 3, true 1, null 0, undefined
and "abc" NaN; an object first with ToPrimitive: its [Symbol.toPrimitive](hint), else
its valueOf() and toString(), keeping the first primitive result, so [5] is 5, {} NaN, a
Date its time and an object whose valueOf() returns 10 is 10) and give a number. Only an
any converts: arithmetic on an object or a Function value is a compile error, as in
TypeScript. + adds when neither side holds a string, an object or a symbol, and
otherwise concatenates the two as String writes them after ToPrimitive (1 + [2] is
"12"); its result is an any, as in TypeScript. == converts an object compared with a
primitive the same way ([] == 0 is true, [1, 2] == "1,2"). x += v, x -= v and the other
compound assignments, x++ and --x work on any variables, fields, elements and members of
an any; x++ gives the old number, and the value written is boxed back into the any. A
number variable combined with an any (n += a) stays a number (when a holds a string,
+= concatenates and the conversion back stops with its TypeError, where Node stores the
string). A
symbol operand stops with Node's TypeError: Cannot convert a Symbol value to a number. Like
the other stops on any, these TypeErrors cannot be caught: a catch around the operator is
never reached (see "These stops are not exceptions" under Intersection
types). Each
conversion of a primitive is one call through the descriptor of the value held; + and the
comparisons test the descriptors' tags before it.
A method called on an any holding a string, an array, a number or a boolean is the built-in
method of that value, with the arguments converted as JavaScript converts them
(s.slice("1"), s.includes(5), a missing argument as undefined): on strings trim,
trimStart, trimEnd, toUpperCase, toLowerCase, slice, substring, indexOf,
lastIndexOf, includes, startsWith, endsWith, split, replace and replaceAll (with
string patterns), charAt, charCodeAt, at, repeat, padStart, padEnd, concat,
localeCompare, toString and valueOf; on arrays push, pop, shift, unshift,
splice, slice, at, reverse, concat, indexOf, lastIndexOf, includes, map,
forEach, filter, find, findIndex, some, every, reduce, sort (by ToString
without a comparator, undefined last), join and toString; on
numbers toFixed, toString (with a radix), toPrecision and valueOf; on booleans
toString and valueOf. Callbacks get each element, its index and the array, as anys; the
result is an any (map gives an any[], filter an array of the elements' own type). An
array keeps its element type: pushing a value of another type stops with the conversion's
TypeError (Node's arrays take anything); an empty literal converted to any
(const xs: any = []) is an any[], which takes every value. Other built-in methods of these
values stop with a TypeError saying they are not supported yet, and a name that is no method of the value is Node's
TypeError: x.foo is not a function. A function held has no methods through any yet: call,
apply, bind and toString stop with a TypeError saying they are not supported yet, and any other name is not a
function.
let n: any = "4";
n++;
const xs: any = [3, 1, 2];
xs.push(n);
console.log(n, xs.map((x: number) => x * 2), xs.includes(5)); // 5 [ 6, 2, 4, 10 ] true
const s: any = " sigx ";
console.log(s.trim().toUpperCase(), s.length - 1, "1" + (n as any) * 2); // SIGX 6 110
const price: any = 2.5;
console.log(price.toFixed(2), price * "2", [2] == (2 as any)); // 2.50 5 truePlanned: JSON.parse into an any, any converted to interface types and the
warning on a written any; until then each is a compile error.
key in x on an any asks the value held, as Node does: a class instance or object type has
its fields, methods and accessors (a class's inherited ones too) and what every object has from
Object.prototype (toString), a Record<string, V> its keys and, unless Object.create(null)
made it, what every object has. An array (or a tuple) has its element indexes (0 in xs,
"1" in xs; not "01"), length and Array.prototype's methods ("push" in xs,
Symbol.iterator in xs); a typed array its indexes and %TypedArray%.prototype's members (a
Uint8Array also toBase64, toHex, setFromBase64 and setFromHex); a function call,
apply, bind, name and length; a Map, Set, WeakMap, WeakSet, Date, RegExp,
Error, ArrayBuffer, DataView or Promise what its JavaScript prototype has
("size" in map, never a map's entries). A function's prototype (which a declared function
has and an arrow or a method has not) stops with a TypeError saying it is not supported yet. A string,
number, boolean or symbol stops with Node's
TypeError: Cannot use 'in' operator to search for 'k' in str, and a null or undefined
value with ... in undefined (a symbol key shown as Node shows it,
'Symbol(Symbol.iterator)'). An optional field of a nullable type in an object type
(a?: T | null), which keeps "absent" apart, is there while present; another optional field of
an object type is there while it holds a value, so { a: undefined } given to an a?: number
field reads as absent.
const C = Symbol("computed");
function isComputed(v: unknown): boolean {
return v !== null && typeof v === "object" && C in v;
}
const xs: any = [1, 2];
console.log(isComputed([1, 2]), 1 in xs, 2 in xs, "push" in xs); // false true false true
const m: any = new Map<string, number>([["a", 1]]);
console.log("size" in m, "a" in m); // true falseA symbol constant is a key on an any too: x[KEY], x[KEY] = v and KEY in x (with
const KEY = Symbol("k"), or a well-known symbol such as Symbol.iterator) name the member
the symbol keys, as on a typed object. A key of type string | symbol (a proxy trap's prop)
reads, writes and tests the member a string names; a symbol known only at run time stops with
a TypeError saying it is not supported yet.
const BRAND = Symbol("brand");
function isBranded(v: any): boolean {
return v !== null && typeof v === "object" && v[BRAND] === true;
}
const b = { [BRAND]: true, name: "b" };
console.log(isBranded(b), isBranded({ name: "c" }), BRAND in (b as any)); // true false trueGeneric interfaces with unknown arguments
A value of a generic interface type with concrete type arguments converts to the same interface
with unknown (or any) arguments: a Handler<Date, number> is a Handler<unknown, unknown>,
so a table of handlers written for different types can be one Handler[]. TypeScript accepts
this because it compares method parameters bivariantly (an interface's type parameter may be a
method parameter's type, serialize(value: T)), which lets a wrong argument through unnoticed.
Velt checks it instead: the converted value wraps the original, and every call through it
converts each argument to the original's own type, checked at run time as a conversion out of
any is (a value of another type stops the program with a TypeError; Node would run the
method with it). Results and fields need no check (an unknown takes any value), and a function
value in a parameter, a result or a field (an optional method revive?(e: E): T is a field of
function type) is wrapped the same way, as is an interface value of a result.
interface Handler<T = unknown, E = unknown> {
name: string;
test(value: unknown): boolean;
serialize(value: T): E;
}
const dateHandler: Handler<Date, number> = {
name: "date",
test: (v) => v instanceof Date,
serialize: (d) => d.getTime(),
};
function encode(value: unknown, handlers: Handler[]): void {
for (const h of handlers) {
if (h.test(value)) {
console.log(h.name, h.serialize(value)); // date 0
}
}
}
encode(new Date(0), [dateHandler]);Calling serialize("x") through the Handler[] stops with TypeError: the value is not a Date (it is a string). The wrapped value is the original object: a write through one is seen
through the other, and every copy of the converted value (an array element read into a
const, a second conversion to Handler<any, any>) wraps that same object. A field assigned
through the converted value (h.name = "x" on a Handler) is assigned on the original, its
value converted to the original's field type and checked as an argument is (a field of an
interface type at unknown arguments does not convert back, a compile error saying it is not supported yet).
The converted value and the original are one object to ===, instanceof, Set and Map
keys, and includes: a Handler<Date, number> read back from a Handler[] is === to it,
instanceof tests the original's class, and a Set<Handler> holding one finds the other. The
same holds once either is an any, unknown or object (which holds the original itself), and
members read or written through such an any are the original's.
A checked conversion from any or unknown back to the interface (v as Handler<any, any>,
or at any other arguments, such as the original's own) takes the value the any holds through
its own implementation of the interface: an object literal's, a class's (also for an instance
of a subclass, through the nearest base class implementing it), or a generic class's at the
instance's type arguments, including instances only generic code makes (new G<T>(x) in a
function mk<T>(x: T)). At other arguments the result is wrapped as above, and it is the same
object as the original. A context stored in a Map<string, unknown> and read back as a
Ctx<any, any> works as in Node. A value implementing the interface at a promise argument
(Ctx<Promise<number>>) does not convert to Ctx<any>: a TypeError.
A field holding an array of the interface's own values (kids: Tree<T>[]), read through a
Tree<number> converted to a Tree<any>, is a new array of the same elements, each a
Tree<any>: reads see the original's elements and their changes. Once a program converts a
Tree to other type arguments this way, the field read through a Tree<any> (or through a
Tree<T> in generic code, which may be one) may only be used in place without changing it:
an element (read, or written through: t.kids[0].value = 7 changes the original's element),
.length, or a method that leaves the array unchanged (map, filter, slice, indexOf,
join, …; a callback that replaces or removes an element of the original during the call may
still be given the element the field held when the method started), also behind ! or ?. (t.kids?.length,
t.kids?.[0]) or after a test of an optional field (if (t.more) t.more[0]), and a
condition or null test of it (if (!t.kids), t.kids && t.kids.length). The same holds for a
method's result of such a type (getKids(): Tree<T>[]). Anything else is a compile error, as Node's result would differ: changing the array (push, splice, sort, an element
or length assigned, the field assigned), keeping it (const k = t.kids, destructuring,
spreading), passing it on, comparing it (t.kids === t.kids is false on a new array), or
iterating it with for…of, entries(), keys() or values() (Node also visits an element the
loop body adds to the original; an index loop reading t.kids.length and t.kids[i] each step
sees them). Change or keep the array through
the value's own type instead. An interface whose function types take or give such an array
(withKids(f: (k: Tree<T>[]) => void), kidsFn: () => Tree<T>[]) does not convert to other
type arguments: a compile error.
interface Tree<T> {
value: T;
kids: Tree<T>[];
size(): number;
}
class Node1<T> implements Tree<T> {
value: T;
kids: Tree<T>[] = [];
constructor(v: T) {
this.value = v;
}
size(): number {
return this.kids.length;
}
}
const t = new Node1<number>(1);
t.kids.push(new Node1<number>(2));
const a = (t as unknown) as Tree<any>;
console.log(a.kids.length, a.kids[0].value); // 1 2
console.log(a.kids.map((k) => k.value).join(",")); // 2
t.kids.push(new Node1<number>(3)); // `a.kids.push(…)` is the compile error
console.log(a.kids.length); // 2A parameter of type unknown[], Map<unknown, unknown> or Set<unknown> (a handler for
arrays, maps or sets, as in a serializer's table) takes an array, map or set of any element
types. The method receives a copy holding the elements boxed, and when it returns the copy is
written back into the caller's array, map or set. Each element written back converts to the
original's own element type, checked as above. Reads and changes the method makes during the
call match Node: it sees every element, and the caller sees what it added, deleted or replaced.
Differences from Node:
- the method's container is not the caller's:
===between the two isfalse, and a reference the method keeps after it returns no longer follows the caller's container; - changing the length of an
unknown[](push,pop) stops with aTypeErrorsaying it is not supported yet.
interface Handler<T = unknown, E = unknown> {
name: string;
serialize(value: T): E;
}
const mapHandler: Handler<Map<unknown, unknown>, [unknown, unknown][]> = {
name: "map",
serialize: (m) => [...m.entries()],
};
const all: Handler[] = [mapHandler];
const scores = new Map<string, number>([["ann", 3]]);
console.log(all[0].serialize(scores)); // [ [ 'ann', 3 ] ]The conversion applies where each type argument is the original's or unknown / any, and
each parameter and result converts as described. It is a compile error, with a note naming the
case, when a method's parameter has another type holding unknown or any (a generic class
such as Box<unknown>, or Map<string, unknown>) or is an interface, and when a
result is a promise of another type, or a parameter is an array of the type parameter. A method
with type parameters of its own converts too. A result that is an object type, or a union of
them, whose fields convert (an iterator's IteratorResult<[any, any]> as IteratorResult<any>)
is a new object of the target type holding the same field values. A parameter of a function value (an optional method
revive?(e: E): T with E an unknown[]) is checked against its own type as it is,
except a generic class with unknown arguments, which is a compile error. A program without
such a conversion pays nothing for it.
Proxy and Reflect
new Proxy(target, handler) makes a proxy as in JavaScript: reading a member of it runs the
handler's get trap, writing one its set trap and key in p its has trap, each with the
target, the key (a string; an index arrives as its string, "1") and, for get and set, the
proxy itself as the receiver. A trap the handler leaves out forwards the operation to the
target, and p.m(a) reads m through get and calls what it returns. A set trap that
returns false makes the assignment throw Node's TypeError: 'set' on proxy: trap returned falsish for property 'k' (what strict code gets). Reflect.get(t, k), Reflect.set(t, k, v)
and Reflect.has(t, k) do what t[k], t[k] = v and k in t do; given a receiver, Reflect.get
and Reflect.set pass it on to a target that is itself a proxy. On a proxy, Reflect.set
answers what the set trap returns, false included, where p.k = v throws. A key access in a
generic function (s[k] with s: T and k: K extends keyof T) runs the traps too, whether the
proxy is an any or typed as its target. A function the get trap answers for a method call
(p.push(x)) runs with the proxy as this when it uses this (a function expression with
this: any, Function expressions).
console.log(p) prints the target, without running traps, as Node does.
The handler's ownKeys trap lists the proxy's own keys and getOwnPropertyDescriptor describes one
of them, so a proxy may have keys only its handler knows (an empty target). As in Node,
Object.keys(p), Object.values(p) and Object.entries(p) run ownKeys, then
getOwnPropertyDescriptor for each string key (and get for an enumerable one, for values and
entries); a spread { ...p } does the same, key by key. Object.hasOwn(p, k) and
Object.prototype.hasOwnProperty.call(p, k) run getOwnPropertyDescriptor. delete p.k and
delete p[k] run the deleteProperty trap, and a trap that returns false throws Node's
TypeError: 'deleteProperty' on proxy: trap returned falsish for property 'k'. A trap the handler
leaves out forwards to the target: its keys are what Object.keys lists, each a data property
(writable, enumerable and configurable; an array's length is not enumerable and not configurable).
Reflect.ownKeys(t), Reflect.deleteProperty(t, k), Reflect.getOwnPropertyDescriptor(t, k) and
Reflect.defineProperty(t, k, attributes) are those operations as functions (a PropertyDescriptor
is { value?, writable?, get?, set?, enumerable?, configurable? }), and a target that is not an
object stops with Node's TypeError: Reflect.ownKeys called on non-object. The checks Node makes on
what these traps answer apply, with Node's TypeErrors: an ownKeys answer must be an array of
strings and symbols without duplicates that lists the target's keys that are not configurable, and a
getOwnPropertyDescriptor, deleteProperty or defineProperty answer must agree with the target's
own property (a property may be reported non-configurable only when the target's is). When the
target is itself a proxy, the target's traps run for these checks, in Node's order. A descriptor the
getOwnPropertyDescriptor trap answers is completed: an accessor (get / set) or a data
property, an attribute left out false (and a data property's value undefined). The handler's
defineProperty trap runs for Reflect.defineProperty(p, k, attributes), and Reflect.set(t, k, v, p) with a proxy p as the receiver runs p's getOwnPropertyDescriptor and defineProperty
traps, as Node does. Reflect.defineProperty of a plain value writes the value of an existing
property; a new property, whose attributes left out are false, is a TypeError saying it is not supported yet.
delete x.k through any and Reflect.deleteProperty(x, k) of a value that is not a proxy remove
a Record's entry and make an object type's optional field absent; a key the value doesn't have as
its own (a class's methods are on its prototype) gives true, and an array's length gives false
(delete a.length throws Node's TypeError: Cannot delete property 'length' of [object Array]). A
field the value's type fixes and a class's field (an own property even when optional) can't be
removed: that is a TypeError saying it is not supported yet. Deleting a string's length or one of its characters
throws Node's TypeError: Cannot delete property '0' of [object String]; another key of a string,
and any key of a number or a boolean, gives true.
A trap sees a symbol key as the symbol (p[KEY], KEY in p, delete p[KEY],
Object.hasOwn(p, KEY) with a symbol constant KEY), and Reflect.get(t, KEY) /
Reflect.set / Reflect.has of a plain value reach the member the symbol names. A handler
typed any (new Proxy(t, handler as any)) works too: its traps are called through any.
JSON.stringify of a proxy reads it through its traps in Node's order (toJSON, then the
keys, whether each is enumerable and their values; an array target's length and elements).
Object.keys(s) of a typed proxy s runs its ownKeys and getOwnPropertyDescriptor traps,
for (k in s) its ownKeys trap once and its getOwnPropertyDescriptor trap just before each
key's turn, Object.hasOwn(s, k) and hasOwnProperty.call(s, k) its
getOwnPropertyDescriptor trap for k, and "k" in s its has trap, as in Node, for a
proxy typed as a class, an interface or an object type (an operand such as a call,
Object.keys(getProps()), is evaluated once). for (k in p) of an any proxy p runs its
ownKeys trap once and its getOwnPropertyDescriptor trap just before each key's turn,
visiting the enumerable ones, so a key deleted during the loop is skipped, as Node does. A
for...in loop evaluates its object once and keeps it: assigning another value to the variable
or property in the body changes neither the keys visited nor the traps run. Whether the value is
a proxy is tested once, before the loop; a plain value's keys (also through any) are listed
when the loop begins, and one deleted during the loop is still visited.
p.push(…), p.pop(), p.shift(), p.unshift(…), p.splice(…), p.map(f), p.filter(f),
p.forEach(f), p.some(f), p.every(f), p.find(f), p.includes(x), p.indexOf(x),
p.join(sep), p.slice(…), p.reverse() and p.sort(compare) on a proxy of an array read the
method through the get trap and run JavaScript's generic algorithm with the proxy as this, as
Node does (the callbacks get the element, its index and the proxy; a thisArg is not passed on yet;
map, filter and slice give a new plain array): every read of length or of an element, every
write (set), in test (has) and delete (deleteProperty) goes through the traps, in Node's
order, and a trap that refuses throws Node's TypeError. A trap that forwards with Reflect
reaches the array: Reflect.has(a, "1") tests an element, and Reflect.get(a, "push") is the
method, a function to typeof and console.log ([Function: push]; Reflect.get(a, "constructor") is [Function: Array]), so a reactive get trap that wraps only objects passes it
through and a nested reactive array runs it. Each read gives a new one, so two reads are not ===
as in Node, and called other than on a proxy it is a TypeError saying it is not supported yet. While such an
algorithm runs, the holes its own trap calls make in its target behave as in Node: pop, shift
and splice delete the last elements (Reflect.deleteProperty(t, k)) before they write the shorter
length, and unshift and splice write an element past the end before they fill the gap. The
array keeps its length and its elements (Velt's arrays have no holes; a gap holds the value written
after it), and Reflect.get, Reflect.has, Reflect.ownKeys, Reflect.getOwnPropertyDescriptor,
Object.hasOwn and hasOwnProperty.call see those elements as absent until the algorithm fills
them or writes the shorter length. A trap that doesn't write it, so that holes would stay, is a
TypeError saying it is not supported yet, and so is a hole in another array or one made in a callback the algorithm
calls (forEach's, sort's comparison). Behind a proxy of a proxy the holes are the array's at the
end of the chain. map and slice of an element that is absent (a has trap answering false, a
hole) are a TypeError saying it is not supported yet (Node keeps the hole in the new array); filter, forEach,
some and every skip it, as in Node. Array.isArray of a proxy of an array, or of a proxy of
one, is true, and JSON.stringify writes it as an array. Elsewhere, deleting an element that is
not past the end (delete a[i] through any, Reflect.deleteProperty(a, i), the last element too)
and Reflect.set(a, i, v) further past the end are TypeErrors saying they are not supported yet. const hasOwn = Object.prototype.hasOwnProperty reads a value whose hasOwn.call(o, k) is Object.hasOwn(o, k).
The handler is a ProxyHandler<T>: its traps' parameters take their types from it, as in
TypeScript (target: any, p: string | symbol, receiver: any). new Proxy(target, handler)
is an any, which converts to the class or object type the context expects (as State, an
annotation, a return type, an argument). The typed value is the same proxy: reading s.count,
writing s.count = 1, s.count += 1 and passing it on run the traps as on the any, s === t and
converting it back to any see one object, and console.log(s) prints the target. A field
read through the get trap is checked against the field's type when it arrives (a TypeError
on a mismatch), so a proxy may stand for a type whose fields its target doesn't have (an empty
target whose get trap answers every key). A method called through a typed proxy runs with
this the proxy, so its reads and writes of this run the traps too. When the handler has a
get trap, s.m(a) first reads m through it, before a is evaluated, as JavaScript does; a
trap that forwards with Reflect.get(target, key) or Reflect.get(target, key, receiver) gives
the target's method, which then runs with this the proxy. A class getter read through a typed
proxy that is a class instance brand (s.twice of sigx's signal(new C())) is the get trap's
answer (its receiver is the proxy itself), checked as the getter's type, as for a field; a
handler without a get trap runs the getter on the target. A trap that forwards it with
Reflect.get(target, key) runs the getter on the target, as Node does, and one that forwards
with Reflect.get(target, key, receiver) stops with a TypeError saying it is not supported yet, since Node would
run the getter with this the proxy (its reads through the traps). Without a brand, a class
getter of a typed proxy runs on the target, which matches Node for a trap that forwards every
key; running getters with this the receiver is planned. A typed proxy passed where an object
type with fewer fields is expected (a State & { $set(v: State): void } passed as a State)
stays the same proxy, so writes through the State run the set trap and reach the target.
(Where a copy of a plain object could be told from the original, such a conversion is a compile
error in a program without proxies; in one that creates proxies, a plain object there throws a
TypeError instead of being copied.)
Calling a function-typed field of a typed proxy (s.$set(v)) reads it through the get trap
before the arguments, then calls the function the trap answered, whatever its own parameter
types (a closure taking any), and checks its result against the field's result type.
s.count++ used as a
value runs the get trap once, and (s.count = v) used as a value is v without a get, as
in JavaScript. A field read through a typed proxy runs after the operands before it, as any
call would (console.log(n, s.count, n) reads the first n before the trap runs).
s instanceof C answers for the proxy's target, as JavaScript does when the handler has no
getPrototypeOf trap. A typed proxy as a WeakMap or WeakSet key, or a WeakRef target, is
the proxy itself, not its target, and deref() gives the proxy back. An entry that refers back
to its key through a typed proxy (a raw -> proxy cache, or a value holding its proxy key) goes
away with the key, as other such entries do. A typed proxy that goes to
another task (spawn, a shared value or Mutex, a channel) is copied like any other value
there: the proxy object together with its target and handler, so it is still a proxy on that
task, and changes there don't reach the original.
A trap may throw, as in JavaScript: its error leaves the operation that ran the trap. A try
around p.k, p[i], p.k = v, k in p, delete p.k, Object.keys(p) (and Object.values,
Object.entries, Object.hasOwn, a spread, for...in, a symbol key) or a Reflect function
called on an any proxy p catches the trap's own error object; a function that doesn't catch
it throws it on (an async one rejects with it), and finally blocks run on the way out. Velt's
typed errors see it too: a trap's errors are inferred from the closures
stored into the handler (any thrown value, as for other function types written without
throws), and in a program whose traps throw, an operation on any throws what the trap it runs
throws (a read what get throws, a write what set throws), so a throws clause around one must
allow it and a catch variable's type includes it. A program whose traps throw nothing is compiled
without any of this. As in Node, a trap may throw any value (throw "a string"): a catch around the
operation gets it.
class ReadOnly extends Error {}
function frozen(target: any): any {
return new Proxy(target, {
set(obj, key, value) {
if (key === "theme") {
throw new ReadOnly(`${String(key)} is read-only`);
}
return Reflect.set(obj, key, value);
},
});
}
const settings = frozen({ theme: "dark", size: 1 });
try {
settings.theme = "light";
} catch (e) {
console.log(e instanceof ReadOnly, (e as Error).message); // true theme is read-only
}
settings.size = 2;
console.log(settings.theme, settings.size); // dark 2Only the types a proxy is converted to (and the classes related to them) check for one, and only
in a program that creates a proxy, so other programs and other types pay nothing. Proxy,
Reflect, ProxyHandler and PropertyDescriptor are globals that a program loads when one of
its modules names them, like fetch and URL (the prelude): a program
that never names them doesn't compile their code, and its operations on any (a spread,
Object.hasOwn, Object.values, Object.entries, delete, o[KEY] with a symbol constant)
don't test for a proxy.
let reads = 0;
function counted(target: any): any {
return new Proxy(target, {
get(obj, prop, receiver) {
reads++;
return Reflect.get(obj, prop);
},
set(obj, prop, value, receiver) {
console.log("set", prop, value);
return Reflect.set(obj, prop, value);
},
});
}
const raw = { count: 0 };
const state = counted(raw);
state.count = 5; // set count 5
console.log(state.count, raw.count, reads); // 5 5 1
console.log("count" in state, "other" in state); // true falsePlanned: a trap's error reaching a method call through an any proxy (p.m()), a
read, write or method call through a typed proxy (s.count of a proxy converted to State),
JSON.stringify of a proxy or an array method running on a proxy of an array is not caught by a
try yet: it ends the process with Node's Uncaught … report, as an error nothing catches does.
Planned: a symbol known only at run time as the key of o[k] on an any (a trap
answering one through Reflect works); a spread of a typed proxy (a TypeError); a completed
descriptor's value: undefined (an optional any field cannot hold a present undefined); the other array methods through a proxy of an array (lastIndexOf, findIndex, reduce,
concat, ...), and Array.prototype[m].apply(p, arguments);
an ownKeys trap answering an array-like object that is not an array; deleting a fixed field,
an array element other than the last or a symbol-keyed member through any from a value
that is not a proxy, and Reflect.defineProperty of a plain value with accessors or
attributes other than true; proxies typed
as an array or a Record, and in on a typed proxy; a function the get trap returns read
as a value from a function-typed field (const f = s.$set), or one it answers for a method name
of a class; an async method called through a proxy with a get trap; a method call through an
interface value holding a proxy whose handler has a get trap; a method read as a value through
any and called on its own; Array.isArray of an array proxy; proxies of functions (apply,
construct); Reflect.get / Reflect.set with a receiver
that is neither the target nor a proxy; destructuring a typed proxy
(const { n, label } = s) and JSON.stringify of one. Until then each is a compile error or a TypeError. A method call through an any proxy whose handler has no traps is the call on
the target. When the receiver is a proxy, a getter of a plain target read by
Reflect.get(t, k, receiver) runs with this the target, and so does a setter written by
Reflect.set(t, k, v, receiver) (Node: the receiver).
object and Function
object holds any value that is not a primitive: a class instance, an object, an array, a map,
a set or a function, but not a number, string, boolean, symbol, null or undefined.
Function holds any function. Both are the dynamic value of any restricted to those values,
with TypeScript's rules:
- Only objects convert to
object, and only functions toFunction(aPoint | nulldoes not convert toobject: test it fornullfirst). Both convert toanyandunknown, aFunctiontoobject, and ananyto either. T extends objecttakes only types whose values are objects, so overloads can tell objects from primitives; inside, aTconverts toobject.- An
objectconverts back withas T, checked like anany(aTypeErrorwhen it holds something else). Its members are TypeScript errors until it is narrowed or converted. - Calling a
Functionis a call throughany: the arguments are converted to the parameters' types when the function runs, and the result is anany. A field of typeFunctionis called the same way (plugin.setup(app)).f.call(thisArg, a, b)andf.apply(thisArg, [a, b])are the callf(a, b)whenthisArgisnullorundefined(or left out:f.call());fandthisArgare evaluated once, before the arguments. A Velt function value has nothisto set, so anotherthisArgstops with aTypeError(Node would call the function with it).f.bind(thisArg)without arguments to fix is a new function forwarding tof(anany) under the same rule. Set<object>andMap<object, V>(andMap<Function, V>) key their entries by identity, as in JavaScript.- An
anyorunknownconverts toobjectwithas object(ananyalso without it), checked at run time: aboolean,number,stringorsymbolis aTypeError(Node would keep the primitive);nullandundefinedpass. - A value of a type parameter without an
objectbound converts withas objecttoo (function toRaw<T>(observed: T) { ... observed as object ... }): aT | nullor a union is unpacked to the value it holds, and a primitive is the sameTypeError.as T & objectis checked the same way, andnullandundefinedare aTypeErrorthere too, sinceT & objectholds neither (asObject(null)stops withthe value is not an object (it is null), and so does anullatT = { a: number } | null; TypeScript keeps thenull). objectcan be aWeakMapkey, aWeakSetmember and aWeakReftarget (WeakMap, WeakSet and WeakRef): the key is the object it holds, whichever type it reachedobjectthrough. A primitive (passed as anany),nullorundefinedfinds nothing inget,hasanddelete, and is JavaScript'sTypeErrorinset,addandnew WeakRef; a value Velt cannot hold weakly yet (a function value, a tuple) is aTypeErrorthere too. A variable passed as a key or target stays alive to the end of its scope.Functionkeys are not supported yet.typeof u === "object"narrows anunknowntoobjectandtypeof u === "function"toFunction; a union withobjectorFunctionis that type when its other members are objects (or functions),nullandundefinedincluded, andunknownotherwise (string | Function).f?.(x)on aFunction | undefined(an optionalonClick?: Functionfield) skips the call when it holdsnullorundefined, as in JavaScript.- A field path of type
anyorunknown(or a union that isunknown,string | Function) narrows as a variable does: aftertypeof vnode.type === "string",vnode.typeis astringthere. A type predicate (isRef(x): x is Ref) narrows anunknownvariable or field path to its type where it returnedtrue(ananyreads its members as it is); the read converts the value held, checked at run time asx as Refis. A member of it (x.__key) is read from the value held, as ananyreads it, and converted to the member's type, so a function with properties held there reads too; an assertion (x as Function) converts the value held, not the narrowed type. A method of it called in place (x.run()) is read the same way. A predicate that returnstruefor a value that is not aRefstops with aTypeErrorwhere the value is read (TypeScript reads what is there,undefinedfor a missing field). - A
T | nullorT | undefinedatT = unknown(orany) takes anunknownas it is, as TypeScript reads it asunknown: a getterget value() { return read(); }of anImpl<unknown>whosevalueisT | nullreturns whatread()returns,undefinedincluded. - An arrow without a written result type asserted to a function type
(
(() => { … return cache.get(key); }) as Use<T>) may return ananyorunknown: TypeScript compares the result it infers with the asserted one, and Velt converts the value ascache.get(key) as Tdoes, checked at run time. x.constructor.nameandx.constructor?.nameof anobject,anyorFunctiongive the name of the constructor of the value held, as in JavaScript: its class's (a subclass's when converted through its base),"Object"for an object literal or a record,"Array","Map","Uint8Array","Number"for a number. A function, or an instance of a standard library class that JavaScript has no constructor of the same name for, stops with aTypeErrorsaying it is not supported yet;nullandundefinedwith Node'sTypeError: Cannot read properties of null (reading 'constructor').x.constructorof anobject,anyorFunctionis the object of the class the value holds, and a class named where anany,unknown,objectorFunctionis expected is that object too, soctor === Set || ctor === Mapandnew Set<unknown>([Set, Map])work as in JavaScript: one object per class (for every instantiation of a generic class),typeof"function". Calling it stops with Node'sTypeError(Class constructor A cannot be invoked without 'new');newthrough it is not supported yet. An ownconstructorproperty comes first. A value that holds no class instance gives its kind's one object: an object literal or a recordObject's (soo.constructor === Object), a stringString's, a numberNumber's (the valueNumbernames where ananyorunknownis expected, son.constructor === Number; called, it converts asNumber(s)does), an array, a boolean or a function an object of its own for each kind (Array,BooleanandFunctionare not values in Velt yet, so=== Arrayis an error saying it can't findArray); a record made byObject.create(null)has none (undefined). They print as in Node:[class A],[class B extends A],[Function: Map],[Function: Array]; the object of a class with static fields or accessors, which Node prints with them ([class S] { count: 2 }), stops with aTypeErrorsaying printing it is not supported yet. A class declared in a function or a block is another class each time its declaration runs in JavaScript: naming one as a plain value is an error, andx.constructorof its instances aTypeError, saying it is not supported yet. A program that readsx.constructorgets one dispatch over its classes and one small constant per class (all of one type): about 11,000 lines of intermediate code and about 110 KB of binary for a two-line program.
class Point {
x: number;
constructor(x: number) {
this.x = x;
}
}
function brand<T extends object>(v: T, seen: Set<object>): T {
seen.add(v);
return v;
}
const seen = new Set<object>();
const p = brand(new Point(1), seen);
const o = brand({ name: "a" }, seen);
console.log(seen.has(p), seen.has(o), seen.has(new Point(1))); // true true false
const handlers: Record<string, Function> = {};
handlers["double"] = (n: number): number => n * 2;
console.log(handlers["double"](21)); // 42
const back = [...seen][0] as Point;
console.log(back.x); // 1Not yet: the members every object has (toString, hasOwnProperty) and a function
has (call, apply and bind with another thisArg, name, length). Each is a compile
error.