Phase 5 · Type-level programming · Lesson 5.1
Advancedkeyof, typeof and indexed access
The three operators that let you derive types from other types and from real values, so a constant array or config object becomes the single source of truth.
20 min
You have a list of roles in an array, and a Role union type written out by hand next to it. Someone adds "auditor" to the array and forgets the union. Nothing complains until production. The fix is to stop writing the type twice: let TypeScript read it off the value. Three small operators make that possible: keyof, typeof and indexed access T[K]. Every advanced type in the rest of this phase is built from them.
keyof: the union of an object type's keys
keyof T produces a union of the property names of T, as literal types.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
interface User {
id: number;
name: string;
email?: string;
}
type UserKey = keyof User;
type _t1 = Expect<Equal<UserKey, "id" | "name" | "email">>;
function get(user: User, key: UserKey) {
return user[key];
}
const u: User = { id: 1, name: "Ada" };
get(u, "name");
// @ts-expect-error -- Argument of type '"age"' is not assignable to parameter of type 'keyof User'.
get(u, "age");Optional properties are still keys: email? shows up in keyof User. keyof is about which names exist, not whether they are set.
Index signatures: the string | number surprise
When the type has an index signature, keyof returns the key type of the signature, not literals. The gotcha: a string index signature gives string | number.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Dict = { [key: string]: boolean };
type NumDict = { [index: number]: boolean };
type _t1 = Expect<Equal<keyof Dict, string | number>>;
type _t2 = Expect<Equal<keyof NumDict, number>>;
type _t3 = Expect<Equal<keyof any, string | number | symbol>>;Why number too? In JavaScript, obj[1] is the same as obj["1"]: numeric keys are converted to strings. A string index signature therefore accepts numeric keys, and keyof reports both. keyof any is string | number | symbol, which is exactly the built-in PropertyKey type.
keyof on unions and intersections
This is the one interviewers like, because it feels backwards at first:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type A = { a: string; shared: number };
type B = { b: boolean; shared: number };
type UnionKeys = keyof (A | B);
type _t1 = Expect<Equal<UnionKeys, "shared">>;
type IntersectionKeys = keyof (A & B);
type _t2 = Expect<Equal<IntersectionKeys, "a" | "b" | "shared">>;Think about what you can safely do with a value:
- A value of type
A | Bmight be anAor aB. The only key you can rely on is one both have. Sokeyof (A | B)=keyof A & keyof B(the common keys). - A value of type
A & Bis both. It has every key of each. Sokeyof (A & B)=keyof A | keyof B(all keys).
Union of types gives the intersection of keys; intersection of types gives the union of keys.
Quick check
What is keyof (Cat | Dog)?
type Cat = { name: string; meow(): void };
type Dog = { name: string; bark(): void };typeof in a type position
JavaScript already has a typeof operator. It runs at runtime and returns a string like "object". TypeScript adds a second, compile-time typeof that you use where a type is expected. It gives you the type of a variable or property.
const config = {
host: "localhost",
port: 8080,
tls: false,
};
type Config = typeof config;
// ^? type Config = { host: string; port: number; tls: boolean }
// Runtime typeof: a JavaScript expression that returns a string.
const kind = typeof config.port;
// ^? const kind: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"Context decides which one you get. After type X =, or after a : annotation, it is the type query. In an expression (like const kind = ... or if (typeof x === "string")), it is the JavaScript operator.
Note that port is number, not 8080. Object properties are mutable, so literal types widen. Keep that in mind; as const fixes it below.
What typeof accepts
The type query only takes an identifier or a property access (typeof obj.a.b). You cannot put a function call or an arbitrary expression in it.
function makeUser() {
return { id: 1, name: "Ada" };
}
type MakeUser = typeof makeUser;
// ^? type MakeUser = () => { id: number; name: string }
type User = ReturnType<typeof makeUser>;
// ^? type User = { id: number; name: string }This is not a type error but a syntax error: the parser does not even accept a call after the type-level typeof.
type Broken = typeof makeUser(); // error: ';' expected.ReturnType<typeof fn> is the idiom for "the type this function returns". You will rebuild ReturnType yourself in the conditional types lesson.
Indexed access: T["key"]
Indexed access types look up the type of a property, the way obj["key"] looks up a value.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
interface User {
id: number;
name: string;
address: { city: string; zip: string };
email?: string;
}
type Id = User["id"];
type City = User["address"]["city"];
type IdOrName = User["id" | "name"];
type Email = User["email"];
type _t1 = Expect<Equal<Id, number>>;
type _t2 = Expect<Equal<City, string>>;
type _t3 = Expect<Equal<IdOrName, number | string>>;
type _t4 = Expect<Equal<Email, string | undefined>>;Four things to notice:
- It chains:
User["address"]["city"]. - A union of keys gives a union of property types.
- An optional property includes
undefined. - The index must be a type. Dot syntax does not work (
User.idis an error), and the key must exist.
interface User {
id: number;
name: string;
}
// @ts-expect-error -- Property 'age' does not exist on type 'User'.
type Age = User["age"];
const key = "name";
// @ts-expect-error -- 'key' refers to a value, but is being used as a type here.
type ByValue = User[key];
type ByTypeof = User[typeof key];
// ^? type ByTypeof = stringThe last example combines two operators: typeof key is the literal type "name", which is a valid index.
T[keyof T]: the union of all value types
Index with every key and you get every property type as a union:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type StatusCodes = {
ok: 200;
notFound: 404;
serverError: 500;
};
type Code = StatusCodes[keyof StatusCodes];
type _t1 = Expect<Equal<Code, 200 | 404 | 500>>;
type ValueOf<T> = T[keyof T];
type _t2 = Expect<Equal<ValueOf<{ a: string; b: number }>, string | number>>;ValueOf<T> is not built in, but you will see it (or write it) in almost every codebase that uses this pattern.
Quick check
What is Theme[keyof Theme]?
const theme = { primary: "#0af", danger: "#e33" } as const;
type Theme = typeof theme;Arrays and tuples: T[number]
Arrays are indexed by numbers, so number is a valid index type for them. T[number] means "the type of any element".
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Names = string[];
type Name = Names[number];
type _t1 = Expect<Equal<Name, string>>;
type Point = [x: number, y: number, label: string];
type First = Point[0];
type AnyElement = Point[number];
type Length = Point["length"];
type _t2 = Expect<Equal<First, number>>;
type _t3 = Expect<Equal<AnyElement, number | string>>;
type _t4 = Expect<Equal<Length, 3>>;
// @ts-expect-error -- Tuple type '[x: number, y: number, label: string]' of length '3' has no element at index '3'.
type Missing = Point[3];Tuples are more precise than arrays: Point[0] is the first element's type, Point["length"] is the literal 3 (a plain array's length is just number), and indexing past the end is an error.
Deriving types from data with as const
Now the payoff: the opening problem. Put the data in one place, freeze it with as const, and read the types off it.
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
const ROLES = ["admin", "editor", "viewer"] as const;
// ^? const ROLES: readonly ["admin", "editor", "viewer"]
type Role = (typeof ROLES)[number];
type _t1 = Expect<Equal<Role, "admin" | "editor" | "viewer">>;
function isRole(value: string): value is Role {
return (ROLES as readonly string[]).includes(value);
}
const PERMISSIONS = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
} as const;
type Permission = (typeof PERMISSIONS)[Role][number];
type _t2 = Expect<Equal<Permission, "read" | "write" | "delete">>;
type RoleFromObject = keyof typeof PERMISSIONS;
type _t3 = Expect<Equal<RoleFromObject, Role>>;
console.log(isRole("admin"), isRole("guest"));▶ Try it in the TypeScript Playground
Read (typeof ROLES)[number] from the inside out: take the value ROLES, get its type (a readonly tuple of literals), then ask for the type of any element. Add "auditor" to the array and Role updates itself. That is the single source of truth.
Why as const matters: without it, ROLES is string[], and (typeof ROLES)[number] is just string. as const makes every property readonly and keeps literal types instead of widening them.
The parentheses in (typeof ROLES)[number] are optional (typeof ROLES[number] parses the same way, the type query binds tighter), but most style guides keep them because the unparenthesized form reads as if you were indexing the value.
Spot the error
This helper is meant to read any setting by name. It does not compile. Why, and how would you fix it?
const defaults = { theme: "dark", fontSize: 14, autosave: true };
function getSetting(key: string) {
return defaults[key];
}Show the answer
key is any string, but defaults only has three known keys, and it has no index signature. Under strict this is Element implicitly has an 'any' type because expression of type 'string' can't be used to index type .... Tie the parameter to the object's real keys, and make it generic so the return type follows the key:
const defaults = { theme: "dark", fontSize: 14, autosave: true };
type Settings = typeof defaults;
function getSetting<K extends keyof Settings>(key: K): Settings[K] {
return defaults[key];
}
const size = getSetting("fontSize");
// ^? const size: number
const theme = getSetting("theme");
// ^? const theme: string
// @ts-expect-error -- Argument of type '"colour"' is not assignable to parameter of type 'keyof Settings'.
getSetting("colour");K extends keyof Settings restricts the key; Settings[K] looks up exactly the matching property type. This K extends keyof T plus T[K] pair is the pattern behind typed getters, pick helpers and event emitters.
Recap
keyof Tis the union ofT's keys as literal types; optional keys are included.- A string index signature makes
keyofreturnstring | number;keyof anyisstring | number | symbol. keyof (A | B)is the common keys;keyof (A & B)is all keys.- Type-level
typeof xgets the type of a value; it only accepts identifiers and property accesses. The JavaScripttypeofruns at runtime and returns a string. T["k"]looks up a property type; a union of keys gives a union of types; optional properties includeundefined.T[keyof T]is the union of all value types;T[number]is the element type of an array or tuple.as constplus(typeof DATA)[number]orkeyof typeof DATAmakes the data the single source of truth.
Interview cards
1 / 7