Emmanuel Akinfulubi

Lesson 10 of 1818 min read

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

keyof gives you the union of an object's keys

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


      

A key that does not exist

TS2345
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

Look up a property's type by its key

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


      

typeof, at the type level

// 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.

get(object, key) with the right return type

// 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


      

And a wrong key is caught

TS2345
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'.

The setter, which needs the same machinery

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


      

The value has to match the key

TS2345
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

extends an object 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

extends a union of literals

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.

Only the keys whose values are strings

// 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


      

A non-string key is now rejected

TS2345
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.

Six ways to derive a type from something that exists

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.

Object.keys forgets which keys they were

TS7053
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'.

Two ways to keep the key types

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

Every derived type in this lesson, erased

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

  1. 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.
  2. Given interface Order { lines: { sku: string; qty: number }[] }, write the type of a single line using indexed access, without repeating the shape.
  3. Write get<T, K extends keyof T> from memory. Then call it with a key that does not exist and read the error.
  4. Write pick(object, keys) that takes an object and an array of its keys, returning only those properties. The signature needs K extends keyof T and a return type of Pick<T, K>.
  5. Take the as const array pattern and derive both the union type and a Record keyed by it. Confirm that adding a member to the array makes the Record demand a new entry.
  6. Write the keysOf helper and use it. Then create an object with an extra property added through any and watch the helper's assumption break at run time.

Recap

  • keyof T is the union of T's keys; T[K] is the type of a property; T[number] is an array's element type.
  • In a type position, typeof value asks 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.keys returns string[] on purpose, because objects can carry more keys than their type admits. A typed helper is fine; just know it contains an assertion.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.