Constraints, keyof and Lookups
Saying what a type parameter is allowed to be, and describing one type in terms of another rather than repeating yourself.
By the end: Constrain a generic, and derive one type from another.
Two related skills. Saying what a type parameter is allowed to be, and describing one type in terms of another rather than writing the same shape twice.
Both come down to the same instinct: your types should have a single source of truth, exactly like your code. When a shape is written down twice, the two copies drift, and the drift is silent.
keyof
interface User {
id: number;
name: string;
email: string;
}
// 'id' | 'name' | 'email' — derived, not typed out by hand.
type UserKey = keyof User;
function describe(key: UserKey): string {
return `field: ${key}`;
}
console.log(describe('id'));
console.log(describe('email'));
// Add a field to User and UserKey updates itself. That is the point.
const keys: UserKey[] = ['id', 'name', 'email'];
console.log(keys.join(', '));
Output
interface User {
id: number;
name: string;
}
type UserKey = keyof User;
function describe(key: UserKey): string {
return `field: ${key}`;
}
console.log(describe('emial'));
What the compiler says
error TS2345: Argument of type '"emial"' is not assignable to parameter of type 'keyof User'.
Indexed access: the type of a property
interface User {
id: number;
name: string;
address: {
city: string;
postcode: string;
};
tags: string[];
}
// Read the type of one property, rather than repeating it.
type UserId = User['id']; // number
type Address = User['address']; // the whole nested shape
type City = User['address']['city']; // string, two levels down
type Tag = User['tags'][number]; // string, the element type
const id: UserId = 7;
const address: Address = { city: 'Leeds', postcode: 'LS1' };
const city: City = 'York';
const tag: Tag = 'admin';
console.log(id, address, city, tag);
// A union of keys gives a union of their types.
type IdOrName = User['id' | 'name']; // number | string
const either: IdOrName = 'Ada';
console.log(either);
Output
// A value.
const defaults = {
retries: 3,
timeoutMs: 5000,
verbose: false,
};
// `typeof defaults` in a TYPE position asks the compiler what type that value
// has. This is not the runtime typeof operator — same word, different context.
type Defaults = typeof defaults;
function configure(options: Defaults): string {
return `${options.retries} retries, ${options.timeoutMs}ms, verbose=${options.verbose}`;
}
console.log(configure(defaults));
console.log(configure({ retries: 1, timeoutMs: 100, verbose: true }));
// Combine the three tools and you can derive a great deal from one value.
type OptionName = keyof typeof defaults; // 'retries' | 'timeoutMs' | 'verbose'
type RetryCount = (typeof defaults)['retries']; // number
const name: OptionName = 'verbose';
const count: RetryCount = 5;
console.log(name, count);
Output
The two together: a properly typed property getter
This is the canonical example, and it is worth understanding line by line because the pattern turns up constantly.
// T is the object. K is constrained to be one of T's keys. The return type is
// looked up from T using K, so it is exactly the type of that property.
function get<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
interface User {
id: number;
name: string;
active: boolean;
}
const user: User = { id: 7, name: 'Ada', active: true };
// Each call gets a precise type back: number, string, boolean.
const id = get(user, 'id');
const name = get(user, 'name');
const active = get(user, 'active');
console.log(id.toFixed(0));
console.log(name.toUpperCase());
console.log(active === true);
Output
function get<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
interface User { id: number; name: string }
const user: User = { id: 7, name: 'Ada' };
console.log(get(user, 'nickname'));
What the compiler says
error TS2345: Argument of type '"nickname"' is not assignable to parameter of type 'keyof User'.
function set<T, K extends keyof T>(object: T, key: K, value: T[K]): T {
return { ...object, [key]: value };
}
interface Settings {
theme: 'light' | 'dark';
fontSize: number;
}
const settings: Settings = { theme: 'light', fontSize: 14 };
console.log(set(settings, 'theme', 'dark'));
console.log(set(settings, 'fontSize', 16));
Output
function set<T, K extends keyof T>(object: T, key: K, value: T[K]): T {
return { ...object, [key]: value };
}
interface Settings {
theme: 'light' | 'dark';
fontSize: number;
}
const settings: Settings = { theme: 'light', fontSize: 14 };
// fontSize is a number, and this is not one. The constraint T[K] ties the
// third argument to the second.
console.log(set(settings, 'fontSize', 'large'));
What the compiler says
error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
Constraints in more depth
Constraining to a shape
// Anything with an id, whatever else it has.
function byId<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
interface Product { id: number; sku: string; price: number }
interface Customer { id: number; email: string }
const products: Product[] = [
{ id: 1, sku: 'desk', price: 250 },
{ id: 2, sku: 'chair', price: 90 },
];
const customers: Customer[] = [{ id: 1, email: 'ada@example.com' }];
// The return type is the specific type, not the constraint, so the extra
// properties are still available.
console.log(byId(products, 2)?.sku);
console.log(byId(customers, 1)?.email);
console.log(byId(products, 99)?.sku ?? 'not found');
Output
Constraining to a union
type Level = 'debug' | 'info' | 'error';
// K is one specific level, not the whole union, which is what lets the
// return type mention it.
function tag<K extends Level>(level: K, message: string): `[${K}] ${string}` {
return `[${level}] ${message}`;
}
console.log(tag('info', 'started'));
console.log(tag('error', 'stopped'));
// The return type is a template literal type, so it carries the level with it.
const t: '[info] ready' = tag('info', 'ready') as '[info] ready';
console.log(t);
Output
Constraining a key to a specific value type
A useful and slightly harder one: accept only the keys of T whose value is a string. This needs a conditional type, which is lesson 17 material, but the shape is worth seeing now because the use case is so common.
// For each key K of T, keep K if T[K] is a string, otherwise discard it.
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
interface User {
id: number;
name: string;
email: string;
active: boolean;
}
// 'name' | 'email' — the number and boolean keys are excluded.
type Sortable = StringKeys<User>;
function sortBy<T, K extends StringKeys<T>>(items: T[], key: K): T[] {
return [...items].sort((a, b) => String(a[key]).localeCompare(String(b[key])));
}
const users: User[] = [
{ id: 2, name: 'Grace', email: 'grace@example.com', active: true },
{ id: 1, name: 'Ada', email: 'ada@example.com', active: false },
];
console.log(sortBy(users, 'name').map((u) => u.name));
console.log(sortBy(users, 'email').map((u) => u.email));
const key: Sortable = 'name';
console.log('a valid sort key:', key);
Output
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
interface User {
id: number;
name: string;
}
function sortBy<T, K extends StringKeys<T>>(items: T[], key: K): T[] {
return [...items].sort((a, b) => String(a[key]).localeCompare(String(b[key])));
}
const users: User[] = [{ id: 1, name: 'Ada' }];
// `id` is a number, so it is not in StringKeys<User>.
console.log(sortBy(users, 'id'));
What the compiler says
error TS2345: Argument of type '"id"' is not assignable to parameter of type '"name"'.
Deriving instead of repeating
The habit this lesson is really teaching. Every one of these avoids writing a shape twice.
const ROLES = ['admin', 'editor', 'viewer'] as const;
interface User {
id: number;
name: string;
role: (typeof ROLES)[number];
meta: { createdAt: string };
}
function findUser(id: number): User | undefined {
return id === 1 ? { id: 1, name: 'Ada', role: 'admin', meta: { createdAt: '2026-01-01' } } : undefined;
}
// 1. The element type of a const array.
type Role = (typeof ROLES)[number];
// 2. The keys of an interface.
type UserField = keyof User;
// 3. The type of one property.
type Meta = User['meta'];
// 4. A function's return type, with undefined removed.
type Found = NonNullable<ReturnType<typeof findUser>>;
// 5. A function's parameters, as a tuple.
type FindArgs = Parameters<typeof findUser>;
// 6. The type of a value you already have.
const defaults = { pageSize: 20, sort: 'name' as const };
type Defaults = typeof defaults;
const role: Role = 'editor';
const field: UserField = 'name';
const meta: Meta = { createdAt: '2026-02-01' };
const found: Found = { id: 1, name: 'Ada', role: 'admin', meta };
const args: FindArgs = [1];
const config: Defaults = { pageSize: 50, sort: 'name' };
console.log(role, field, meta.createdAt);
console.log(found.name, args[0], config.pageSize);
console.log('roles at run time:', ROLES.join(', '));
Output
A practical example: type-safe object iteration
Object.keys returns string[], which loses everything you knew. That is deliberate on the library's part — an object might have more keys at run time than its type admits — but it is annoying when you know it does not.
interface Scores {
maths: number;
english: number;
}
const scores: Scores = { maths: 70, english: 65 };
for (const key of Object.keys(scores)) {
// `key` is string, and a general string cannot index Scores.
console.log(key, scores[key]);
}
What the compiler says
error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Scores'.
interface Scores {
maths: number;
english: number;
}
const scores: Scores = { maths: 70, english: 65 };
// 1. A typed helper. The assertion is contained in one place and documented.
function keysOf<T extends object>(object: T): (keyof T)[] {
return Object.keys(object) as (keyof T)[];
}
for (const key of keysOf(scores)) {
console.log('keysOf:', key, scores[key]);
}
// 2. Object.entries, which pairs them up. The value type survives; the key
// still needs help, so this is best when you only need the values.
for (const [key, value] of Object.entries(scores)) {
console.log('entries:', key, value.toFixed(0));
}
const total = Object.values(scores).reduce((a, b) => a + b, 0);
console.log('total:', total);
Output
What all of this compiles to
const ROLES = ['admin', 'editor', 'viewer'] as const;
interface User {
id: number;
role: (typeof ROLES)[number];
}
type Role = (typeof ROLES)[number];
type UserKey = keyof User;
function get<T, K extends keyof T>(object: T, key: K): T[K] {
return object[key];
}
const user: User = { id: 1, role: 'admin' };
const key: UserKey = 'role';
const role: Role = get(user, 'role');
console.log(role, key);
console.log('ROLES survives because it is a value:', ROLES.length);
What this compiles to
const ROLES = ['admin', 'editor', 'viewer'];
function get(object, key) {
return object[key];
}
const user = { id: 1, role: 'admin' };
const key = 'role';
const role = get(user, 'role');
console.log(role, key);
console.log('ROLES survives because it is a value:', ROLES.length);
Output
Only ROLES and the get function survive, because only those are values. keyof, indexed access and type-level typeof are all compile-time computations, so their entire cost is paid during the build.
Your turn
- Write an interface with four properties and derive its key union with
keyof. Add a fifth property and confirm you did not have to update anything. - Given
interface Order { lines: { sku: string; qty: number }[] }, write the type of a single line using indexed access, without repeating the shape. - Write
get<T, K extends keyof T>from memory. Then call it with a key that does not exist and read the error. - Write
pick(object, keys)that takes an object and an array of its keys, returning only those properties. The signature needsK extends keyof Tand a return type ofPick<T, K>. - Take the
as constarray pattern and derive both the union type and aRecordkeyed by it. Confirm that adding a member to the array makes theRecorddemand a new entry. - Write the
keysOfhelper and use it. Then create an object with an extra property added throughanyand watch the helper's assumption break at run time.
Recap
keyof Tis the union ofT's keys;T[K]is the type of a property;T[number]is an array's element type.- In a type position,
typeof valueasks the compiler what type a value has. Same keyword as the runtime operator, entirely different job. function get<T, K extends keyof T>(o: T, k: K): T[K]is the shape to memorise: one type parameter constrained by another, so the return type depends on the argument's value.- Constrain to the shape you use, and the return type stays the specific type rather than collapsing to the constraint.
- Derive types rather than writing a shape twice. Two hand-written copies drift, and nothing warns you.
Object.keysreturnsstring[]on purpose, because objects can carry more keys than their type admits. A typed helper is fine; just know it contains an assertion.