bastianplsfix

keyof and indexed access

keyof T is the union of T's keys. T[K] is the type of whatever lives at those keys. Between them you can say what a type is in terms of another type, instead of writing the same names twice and hoping the two copies stay in step. The third operator belongs with them, because on its own keyof can only read types you wrote by hand: typeof value goes the other way, from a value to its type, and it is what anchors a derived type to real data.

One note about how this entry proves things. Most claims here are annotations: a value of the derived type is accepted and a value that should not be is refused, which you can read at a glance. Claims of the shape "exactly this union and nothing wider" need assertType<IsExact<A, B>>(true) from @std/testing/types, and unions too large to write out need Has, both of which the testing types page takes apart.

Create programs/keyof-and-indexed-access.test.ts for this reference and keep it open, starting with the imports:

import { assert, assertEquals, assertStrictEquals } from "@std/assert";
import { assertType, type Has, type IsExact } from "@std/testing/types";

Below the imports, add the types, objects, and functions the whole page shares; each earns its explanation in its step.

type Person = {
givenName: string;
familyName: string;
age: number;
};

const grace = {
givenName: "Grace",
familyName: "Hopper",
age: 36,
};

type GraceKey = keyof typeof grace;

type ValueOf<T> = T[keyof T];

type Settings = {
host: string;
port: number;
};

const settings: Settings = { host: "localhost", port: 8080 };

type Parsers = {
string: (raw: string) => string;
number: (raw: string) => number;
boolean: (raw: string) => boolean;
};

const parsers: Parsers = {
string: (raw) => raw,
number: (raw) => Number(raw),
boolean: (raw) => raw === "true",
};

function parse<K extends keyof Parsers>(
kind: K,
raw: string,
): ReturnType<Parsers[K]> {
return parsers[kind](raw) as ReturnType<Parsers[K]>;
}

function get<O extends object, K extends keyof O>(object: O, key: K): O[K] {
return object[key];
}

Follow the page as you add and revise the runnable examples below it.

the union of keys, and the type at a key

Deno.test("the union of keys, and the type at a key", () => {
const key: keyof Person = "familyName";
const age: Person["age"] = 36;
const either: Person[keyof Person] = "Lovelace";

// @ts-expect-error: Type '"middleName"' is not assignable to type 'keyof Person'.
const notAKey: keyof Person = "middleName";

assertType<IsExact<keyof Person, "givenName" | "familyName" | "age">>(true);
assertType<IsExact<Person[keyof Person], string | number>>(true);

assertEquals([key, age, either], ["familyName", 36, "Lovelace"]);
assertStrictEquals(notAKey, "middleName");
});
Check programs/keyof-and-indexed-access.test.ts
running 1 test from ./programs/keyof-and-indexed-access.test.ts
the union of keys, and the type at a key ... ok (298µs)

ok | 1 passed | 0 failed (1ms)

keyof Person is "givenName" | "familyName" | "age", which is why "middleName" is refused. Person["age"] is number, indexing the type the way brackets index the object. And Person[keyof Person] is the type of what you find at any key at all, which here is string | number.

typeof crosses from a value to its type

Deno.test("typeof crosses from a value to its type", () => {
assertType<IsExact<GraceKey, "givenName" | "familyName" | "age">>(true);
assertType<IsExact<ValueOf<typeof grace>, string | number>>(true);

// The same three names, at the level where they are strings.
assertEquals(Object.keys(grace), ["givenName", "familyName", "age"]);
});
typeof crosses from a value to its type ... ok (64µs)

typeof grace is the type the checker inferred for the object, and keyof typeof grace reads right to left as two steps: cross from the value to its type, then ask the type for its keys. The two typeofs in TypeScript are unrelated, which the what a type is page covers: the one you can run returns a string like "object", and this one is a type operator that never runs. ValueOf<T> is the operator TypeScript does not ship, since there is a keyof and no valueof, and one line of indexed access is the whole of it. The Object.keys line is worth as much as the two assertions above it: it asks the same question at run time and gets three strings, keyof typeof grace asks at compile time and gets three literal types, and nothing in the language connects them, so the agreement is something you arrange and the assertions check.

derive the list, and renames stop compiling

Deno.test("derive the list, and renames stop compiling", () => {
const printable: Array<GraceKey> = ["givenName", "familyName"];

// @ts-expect-error: Type '"name"' is not assignable to type '"givenName" | "familyName" | "age"'.
const stale: Array<GraceKey> = ["name"];

assertEquals(printable.map((key) => grace[key]), ["Grace", "Hopper"]);
assertEquals(stale, ["name"]);
});
derive the list, and renames stop compiling ... ok (65µs)

Rename givenName in the object and the printable list stops compiling. That is the whole return on deriving rather than repeating, and it is why keyof earns its place in ordinary code that has nothing clever in it. One empirical correction to note in the shield: the checker prints the union in declaration order, "givenName" | "familyName" | "age", and folds nothing here because the element type of a rejected array literal has already been expanded.

a type with no properties has no keys

Deno.test("a type with no properties has no keys", () => {
assertType<IsExact<keyof object, never>>(true);
assertType<IsExact<keyof unknown, never>>(true);

// An index signature is the opposite case: every key, so `keyof` is wide.
assertType<IsExact<keyof Record<PropertyKey, never>, PropertyKey>>(true);
assertType<IsExact<PropertyKey, string | number | symbol>>(true);

assertEquals(Object.keys({}), []);
});
a type with no properties has no keys ... ok (24µs)

never here is not a failure. It is the empty union, which is exactly right for "this type has no keys I can name", and it behaves the way the any, unknown, and never page describes: nothing is assignable to it, so a variable typed keyof object can never be given a value. The other end of the range is PropertyKey, the built-in name for string | number | symbol, and an index signature over it makes keyof maximally wide. Any object type sits somewhere between the two.

keyof tells 0 from "0", and the run time does not

Deno.test('keyof tells 0 from "0", and the run time does not', () => {
const numbered = { 0: "zero", "1": "one" };
type NumberedKey = keyof typeof numbered;

assertType<IsExact<NumberedKey, 0 | "1">>(true);

const unquoted: NumberedKey = 0;
// @ts-expect-error: Type '"0"' is not assignable to type '0 | "1"'.
const quoted: NumberedKey = "0";

// Indexed access is the forgiving one: both spellings find the property.
assertType<IsExact<(typeof numbered)[0], string>>(true);
assertType<IsExact<(typeof numbered)["0"], string>>(true);
assertType<IsExact<(typeof numbered)[1], string>>(true);

assertEquals(Object.keys(numbered), ["0", "1"]);
assertStrictEquals(numbered[unquoted], numbered[quoted]);
assertEquals(Object.keys(["a", "b"]), ["0", "1"]);
});
keyof tells 0 from "0", and the run time does not ... ok (37µs)

An unquoted number key becomes a number literal type and a quoted one becomes a string literal type, so keyof reports 0 | "1" for an object whose two keys you would have called identical. JavaScript disagrees: property keys are strings, Object.keys returns ["0", "1"], and the two lookups reach the same property, which the assertStrictEquals proves. Array indices are the same story, which is why the third run-time assertion is there. So keyof is stricter than the run time here, and a derived key type can refuse a string that would have worked, while indexed access is the forgiving one: T[0] and T["0"] both find the property. One operator keeps the distinction and the other drops it, in the same type, which is worth knowing before it costs you an afternoon.

a string index signature has number keys too

The dictionary promises a number for every string key. Predict the key list after writing through a number index, expecting insertion order:

Deno.test("a string index signature has number keys too", () => {
type StringDictionary = { [key: string]: number };
type NumberDictionary = { [key: number]: number };

assertType<IsExact<keyof StringDictionary, string | number>>(true);
assertType<IsExact<keyof NumberDictionary, number>>(true);

const counts: StringDictionary = { a: 1 };
counts[2] = 2;

assertStrictEquals(counts[2], counts["2"]);
assertEquals(Object.keys(counts), ["a", "2"]);
});
Check programs/keyof-and-indexed-access.test.ts
running 6 tests from ./programs/keyof-and-indexed-access.test.ts
...
a string index signature has number keys too ... FAILED (7ms)

ERRORS

a string index signature has number keys too => ./programs/keyof-and-indexed-access.test.ts:114:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
- "2",
"a",
+ "2",
]

FAILURES

a string index signature has number keys too => ./programs/keyof-and-indexed-access.test.ts:114:6

FAILED | 5 passed | 1 failed (9ms)

error: Test failed

Two things happened, one per level. counts[2] = 2 is legal against a string index signature because a number key is a string key at run time, so an object that accepts every string key necessarily accepts every number key, and keyof StringDictionary says so by reporting string | number for the string you wrote. The property it creates is called "2", and it enumerates first because integer-index keys jump the queue, the key order rule the objects as dictionaries page measures in full. Correct the prediction:

Deno.test("a string index signature has number keys too", () => {
type StringDictionary = { [key: string]: number };
type NumberDictionary = { [key: number]: number };

assertType<IsExact<keyof StringDictionary, string | number>>(true);
assertType<IsExact<keyof NumberDictionary, number>>(true);

const counts: StringDictionary = { a: 1 };
counts[2] = 2;

assertStrictEquals(counts[2], counts["2"]);
assertEquals(Object.keys(counts), ["2", "a"]);
});
a string index signature has number keys too ... ok (27µs)

The reverse does not hold, which is why keyof NumberDictionary is just number: accepting every number key promises nothing about "host". This is the paragraph the object types page points at when it says a Record<string, T> and an index signature have different keyofs. A Record is a mapped type over the key you gave it, so keyof Record<"a", T> is "a" and nothing else, while an index signature is a claim about a possibly infinite set of keys; the mapped types page is where that difference gets written out.

the keys of an array include everything you can call

Deno.test("the keys of an array include everything you can call", () => {
assertType<Has<keyof Array<string>, number>>(true);
assertType<Has<keyof Array<string>, "length" | "push" | "join">>(true);

// A tuple adds one stringified index per element, not a number literal.
assertType<Has<keyof ["a", "b"], "0" | "1">>(true);
assertType<Has<keyof ["a", "b"], number | "length" | "push">>(true);
assertType<Has<keyof ["a", "b"], 0>>(false);

assert(Object.getOwnPropertyNames(Array.prototype).includes("push"));
});
the keys of an array include everything you can call ... ok (24µs)

keyof Array<string> is number for the indices, plus "length", plus the name of every array method. There is no way to write that union out, so these use Has, the standard library's "is this among them" check, rather than IsExact. A tuple's keys are the array's keys and one stringified index per element: "0" | "1" for a pair, and the fifth assertion pins that it is not 0 | 1, because a number literal disappears into a union with number and the string does not. Getting the indices back out of that is a job for the tuple types page. The practical consequence is small and constant: keyof on anything array-shaped gives you far more than the positions, so a type that means "an index of this tuple" needs filtering rather than a bare keyof.

an intersection unions the keys, a union intersects them

Deno.test("an intersection unions the keys, a union intersects them", () => {
type Identified = { id: string; shared: string };
type Timestamped = { at: Date; shared: string };

assertType<
IsExact<keyof (Identified & Timestamped), "id" | "at" | "shared">
>(true);
assertType<IsExact<keyof (Identified | Timestamped), "shared">>(true);

const both: Identified & Timestamped = {
id: "a1",
shared: "x",
at: new Date(0),
};
assertEquals(Object.keys(both), ["id", "shared", "at"]);
});
an intersection unions the keys, a union intersects them ... ok (451µs)

This looks backwards until you say it out loud. A value of Identified & Timestamped has the properties of both, so it has all three keys, which the run-time object confirms. A value of Identified | Timestamped is one or the other and you do not know which, so the only key you can count on is the one they share. keyof is not distributing over the union or failing to; it is answering honestly about what you are allowed to read. The unions and narrowing page is the entry about getting past that restriction, and its discriminated unions are the case where the shared key is the tag.

Tup[number] is the union of a tuple's elements

Deno.test("Tup[number] is the union of a tuple's elements", () => {
const sizes = ["small", "medium", "large"] as const;
type Size = (typeof sizes)[number];

const chosen: Size = "medium";
// @ts-expect-error: Type '"enormous"' is not assignable to type '"small" | "medium" | "large"'.
const invented: Size = "enormous";

assertType<IsExact<Size, "small" | "medium" | "large">>(true);
assertEquals([...sizes], ["small", "medium", "large"]);
assertStrictEquals(sizes.includes(chosen), true);
assertStrictEquals(invented, "enormous");
});
Tup[number] is the union of a tuple's elements ... ok (25µs)

This is the single most useful indexed access there is. as const makes the array a readonly tuple of literal types, the move the read-only page owns, [number] asks for the type at every index, and the result is the union of the elements: one list of strings, written once, usable as data and as a type. Without as const the array's type is string[], so (typeof sizes)[number] would be string and the union would be gone. The pattern this replaces is a union and an array declared separately and kept in step by hand, and the direction is fixed: Object.keys cannot give you a tuple, so a list you need at both levels has to be written as the array and derived as the type, never the reverse, the closing rule of the enum patterns page.

T[K] needs K to be a key

Strip the shield and ask for the type at every string key of a type that made no such promise:

Deno.test("T[K] needs K to be a key", () => {
type AnyValue = Settings[string];

// An index signature is the promise that makes the same lookup legal.
type Loose = { [key: string]: string | number; host: string; port: number };
assertType<IsExact<Loose[string], string | number>>(true);

assertEquals(Object.keys(settings), ["host", "port"]);
});
Check programs/keyof-and-indexed-access.test.ts
TS2537 [ERROR]: Type 'Settings' has no matching index signature for type 'string'.
type AnyValue = Settings[string];
~~~~~~
at file:///programs/keyof-and-indexed-access.test.ts:172:28

error: Type checking failed.

Settings[string] asks for the type at every string key, and Settings has made promises about two of them. The error names the fix in passing: give the type an index signature and T[string] becomes legal, which is exactly what made keyof StringDictionary wide two steps ago. Shield the probe and keep the legal version beside it:

Deno.test("T[K] needs K to be a key", () => {
// @ts-expect-error: Type 'Settings' has no matching index signature for type 'string'.
type AnyValue = Settings[string];

// An index signature is the promise that makes the same lookup legal.
type Loose = { [key: string]: string | number; host: string; port: number };
assertType<IsExact<Loose[string], string | number>>(true);

assertEquals(Object.keys(settings), ["host", "port"]);
});
T[K] needs K to be a key ... ok (42µs)

a lookup table maps a name to a type

Deno.test("a lookup table maps a name to a type", () => {
const port: number = parse("number", "8080");
const debug: boolean = parse("boolean", "true");

assertStrictEquals(port, 8080);
assertStrictEquals(debug, true);
assertStrictEquals(parse("string", "as-is"), "as-is");
});
a lookup table maps a name to a type ... ok (30µs)

parse("number", ...) returns a number and parse("boolean", ...) returns a boolean, from the one module-scope signature. The name of the first argument selects the type of the result through two hops: Parsers[K] is the function at that key, and ReturnType<> is what it gives back. The overloading page reaches the same effect with two signatures instead, and says when each is the better choice. An object type used this way is a lookup table, and it is how the DOM types work: one enormous interface from event name to event type, indexed by whatever you passed to addEventListener.

any object and any of its keys

The generic version of the same move is the module-scope get, a function that takes any object and any of its keys. What makes it worth showing is not the two return types, which you would expect, but what happens when the key is wrong:

Deno.test("any object and any of its keys", () => {
const name: string = get(grace, "givenName");
const age: number = get(grace, "age");

get(settings, "hostname");

assertStrictEquals(name, "Grace");
assertStrictEquals(age, 36);
assertStrictEquals(get(settings, "port"), 8080);
});
Check programs/keyof-and-indexed-access.test.ts
TS2345 [ERROR]: Argument of type '"hostname"' is not assignable to parameter of type 'keyof Settings'.
get(settings, "hostname");
~~~~~~~~~~
at file:///programs/keyof-and-indexed-access.test.ts:195:17

error: Type checking failed.

A typo in a property name, caught at the call site of a function that knows nothing about Settings, and the K extends keyof O in the signature is doing all of it. Shield the typo to keep the refusal on the page:

Deno.test("any object and any of its keys", () => {
const name: string = get(grace, "givenName");
const age: number = get(grace, "age");

// @ts-expect-error: Argument of type '"hostname"' is not assignable to parameter of type 'keyof Settings'.
get(settings, "hostname");

assertStrictEquals(name, "Grace");
assertStrictEquals(age, 36);
assertStrictEquals(get(settings, "port"), 8080);
});
any object and any of its keys ... ok (21µs)

making the compiler print it

The hard part of computing with types is not writing them, it is finding out what you computed, and the probe technique from the testing types page gets one more tool here. Write three deliberate mistakes:

Deno.test("making the compiler print it", () => {
type Point = { x: number; y: number };

const folded: keyof Point = 0;

const expanded: keyof Point & string = 0;

const anonymous: keyof { x: number; y: number } = 0;

assertStrictEquals(folded as unknown, expanded as unknown);
assertStrictEquals(anonymous as unknown, 0);
});
Check programs/keyof-and-indexed-access.test.ts
TS2322 [ERROR]: Type '0' is not assignable to type 'keyof Point'.
const folded: keyof Point = 0;
~~~~~~
at file:///programs/keyof-and-indexed-access.test.ts:206:9

TS2322 [ERROR]: Type '0' is not assignable to type '"x" | "y"'.
const expanded: keyof Point & string = 0;
~~~~~~~~
at file:///programs/keyof-and-indexed-access.test.ts:208:9

TS2322 [ERROR]: Type '0' is not assignable to type '"x" | "y"'.
const anonymous: keyof { x: number; y: number } = 0;
~~~~~~~~~
at file:///programs/keyof-and-indexed-access.test.ts:210:9

Found 3 errors.

error: Type checking failed.

Three probes, one useless answer and two useful ones. The compiler prints keyof Point when the type has a name it can fold to, and prints the members when it has already reduced the type to them: intersecting with string does that, and so does asking about an object type that was never given a name. Worth knowing which forms do not work: Extract<keyof Point, string> still prints keyof Point, and so does a template literal around it, so the one that reliably expands is the intersection. Shield the probes:

Deno.test("making the compiler print it", () => {
type Point = { x: number; y: number };

// @ts-expect-error: a named type stays folded
const folded: keyof Point = 0;

// @ts-expect-error: an intersection with string is reduced, so it prints
const expanded: keyof Point & string = 0;

// @ts-expect-error: an object type with no name has nothing to fold to
const anonymous: keyof { x: number; y: number } = 0;

assertStrictEquals(folded as unknown, expanded as unknown);
assertStrictEquals(anonymous as unknown, 0);
});
making the compiler print it ... ok (11µs)

a table keyed by a derived union cannot go stale

Deno.test("a table keyed by a derived union cannot go stale", () => {
type Point = { x: number; y: number };

const labels: Record<keyof Point, string> = { x: "across", y: "down" };

// @ts-expect-error: Property 'y' is missing in type '{ x: string; }' but required in type 'Record<keyof Point, string>'.
const stale: Record<keyof Point, string> = { x: "across" };

assertEquals(labels, { x: "across", y: "down" });
assertEquals(Object.keys(stale), ["x"]);
});
a table keyed by a derived union cannot go stale ... ok (25µs)

Use Record<keyof T, X> when you need one entry per key. Add a property to Point and labels stops compiling until you fill it in, which is the same exhaustiveness argument the unions and narrowing page makes for a tag union, and the shielded line shows the refusal a missing entry earns today.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/branding.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes-as-values.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/enum-patterns.test.ts
Check programs/enums.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/function-types.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/interfaces-and-type-aliases.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/keyof-and-indexed-access.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/object-types.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/overloading.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/testing-types.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/typed-arrays.test.ts
Check programs/typing-arrays.test.ts
Check programs/typing-classes.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 9 tests from ./programs/branding.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
...
running 10 tests from ./programs/classes-as-values.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 12 tests from ./programs/enum-patterns.test.ts
...
running 7 tests from ./programs/enums.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 12 tests from ./programs/function-types.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 9 tests from ./programs/interfaces-and-type-aliases.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/keyof-and-indexed-access.test.ts
the union of keys, and the type at a key ... ok (334µs)
typeof crosses from a value to its type ... ok (27µs)
derive the list, and renames stop compiling ... ok (57µs)
a type with no properties has no keys ... ok (20µs)
keyof tells 0 from "0", and the run time does not ... ok (33µs)
a string index signature has number keys too ... ok (27µs)
the keys of an array include everything you can call ... ok (21µs)
an intersection unions the keys, a union intersects them ... ok (34µs)
Tup[number] is the union of a tuple's elements ... ok (24µs)
T[K] needs K to be a key ... ok (16µs)
a lookup table maps a name to a type ... ok (21µs)
any object and any of its keys ... ok (16µs)
making the compiler print it ... ok (13µs)
a table keyed by a derived union cannot go stale ... ok (21µs)
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 15 tests from ./programs/object-types.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 8 tests from ./programs/overloading.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 14 tests from ./programs/testing-types.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 14 tests from ./programs/typed-arrays.test.ts
...
running 10 tests from ./programs/typing-arrays.test.ts
...
running 12 tests from ./programs/typing-classes.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 758 passed | 0 failed (885ms)

Fourteen tests, and the practice is short. Derive the keys from the object, never the object from the keys, because a list of key names written out by hand is a second source of truth and keyof typeof theObject is one line; the exception is a list you need as a value, since Object.keys cannot produce a tuple, so write the array, derive the union with [number], and check the two agree. Use Record<keyof T, X> when you need one entry per key, because a table keyed by a derived union cannot go stale. Remember that number keys are not string keys at this level, that the distinction disappears the moment the program runs, and that a derived key type refusing a string you were sure about usually means the key was written unquoted. Reach for PropertyKey rather than string | number | symbol, which is the same type and says why. Print a type before you assert it: annotate something with it, assign the wrong value, and intersect with string if the message folds the answer back into an alias. And do not build much on top of this. keyof and T[K] earn their place in code that has no other type-level machinery in it at all, which is most code; the conditional types and mapped types pages are the next steps and both are easy to overuse, and these two operators are not.