Skip to content

Advanced Types

The type-level programming layer: operators that compute new types from existing ones. This is what lets a library describe “the return type of whatever function you passed me” instead of making you write it out.

keyof T produces the union of T’s property names.

interface User {
id: number;
name: string;
email: string;
}
type UserKey = keyof User; // 'id' | 'name' | 'email'
let k: UserKey = 'name';
k = 'age'; // error: Type '"age"' is not assignable to type 'keyof User'

On a type with an index signature you get the index type back:

type Dict = { [key: string]: number };
type DictKey = keyof Dict; // string | number
type NumDict = { [key: number]: string };
type NumKey = keyof NumDict; // number

keyof any is string | number | symbol, which is also spelled PropertyKey.

In a type position, typeof x means “the type of the value x”. This is unrelated to JavaScript’s runtime typeof, which appears in expression positions.

const defaults = {
retries: 3,
timeout: 5000,
verbose: false,
};
type Defaults = typeof defaults;
// { retries: number; timeout: number; verbose: boolean }
function apply(overrides: Partial<Defaults>) {}

This is the standard way to derive a type from a value you already wrote, so the two can’t drift.

It works on functions and classes too:

function makeUser(name: string, age: number) {
return { name, age, createdAt: new Date() };
}
type MakeUser = typeof makeUser; // (name: string, age: number) => {...}
type User = ReturnType<typeof makeUser>; // { name: string; age: number; createdAt: Date }
class Service {}
type ServiceCtor = typeof Service; // the constructor, not an instance

Combined with as const, it turns runtime constants into precise types:

const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number]; // 'admin' | 'editor' | 'viewer'

T[K] is the type of T’s K property, where K can be a union.

interface Product {
id: string;
price: number;
tags: string[];
}
type Price = Product['price']; // number
type IdOrPrice = Product['id' | 'price']; // string | number
type AnyValue = Product[keyof Product]; // string | number | string[]
type Tag = Product['tags'][number]; // string — element type of an array

[number] on an array or tuple type gives the element type — the idiom used with as const above.

A extends B ? X : Y is a type-level ternary. It asks “is A assignable to B?”.

type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false

When the checked type is a naked type parameter and you pass a union, the conditional distributes across each member and the results are unioned.

type ToArray<T> = T extends unknown ? T[] : never;
type X = ToArray<string | number>; // string[] | number[] (not (string | number)[])

That’s the mechanism behind Exclude and Extract. To switch distribution off, wrap both sides in a tuple:

type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Y = ToArrayNonDist<string | number>; // (string | number)[]

infer declares a type variable inside the extends clause and captures whatever matched.

type ElementOf<T> = T extends (infer E)[] ? E : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<number>; // never
type Unwrap<T> = T extends Promise<infer V> ? V : T;
type C = Unwrap<Promise<User>>; // User
type D = Unwrap<string>; // string

You can infer from function signatures, tuples, and constructors:

type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type MyParameters<F> = F extends (...args: infer P) => any ? P : never;
type Head<T extends unknown[]> = T extends [infer H, ...unknown[]] ? H : never;
type Tail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;
type E = Head<[string, number, boolean]>; // string
type F = Tail<[string, number, boolean]>; // [number, boolean]

infer supports constraints (TypeScript 4.7+):

type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;

Conditional types may reference themselves, which enables real computation.

type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type Flatten<T> = T extends readonly (infer E)[] ? Flatten<E> : T;
type G = Flatten<number[][][]>; // number

A mapped type builds a new object type by iterating over keys.

type Optional<T> = {
[K in keyof T]?: T[K];
};
type Stringify<T> = {
[K in keyof T]: string;
};
interface User { id: number; name: string }
type A = Optional<User>; // { id?: number; name?: string }
type B = Stringify<User>; // { id: string; name: string }

readonly and ? can be added with + (implied) or removed with -.

type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type Concrete<T> = {
[K in keyof T]-?: T[K]; // removes optionality AND undefined from the type
};
type Frozen<T> = {
+readonly [K in keyof T]+?: T[K]; // the + is optional; this is `Readonly<Partial<T>>`
};

Since TypeScript 4.1, as in a mapped type rewrites each key.

type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User { id: number; name: string }
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }

Mapping a key to never removes it — this is how Omit is implemented:

type RemoveField<T, Field extends keyof T> = {
[K in keyof T as K extends Field ? never : K]: T[K];
};
type WithoutId = RemoveField<User, 'id'>; // { name: string }

Filtering by value type:

type OnlyFunctions<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};
interface Api {
url: string;
get(): void;
post(): void;
}
type ApiMethods = OnlyFunctions<Api>; // { get(): void; post(): void }

You can also map over an arbitrary union, not just keyof T:

type Flags = { [K in 'debug' | 'verbose']: boolean };
// { debug: boolean; verbose: boolean }

String literal types you build with interpolation, added in TypeScript 4.1.

type Greeting = `hello ${string}`;
const a: Greeting = 'hello world'; // fine
const b: Greeting = 'goodbye'; // error

Interpolating unions produces the cross product:

type Size = 'sm' | 'md' | 'lg';
type Side = 'top' | 'bottom';
type Margin = `m${'t' | 'b'}-${Size}`;
// 'mt-sm' | 'mt-md' | 'mt-lg' | 'mb-sm' | 'mb-md' | 'mb-lg'
type CssVar = `--${string}`;

Four intrinsic string types are built in:

type A = Uppercase<'hello'>; // 'HELLO'
type B = Lowercase<'HELLO'>; // 'hello'
type C = Capitalize<'hello'>; // 'Hello'
type D = Uncapitalize<'Hello'>; // 'hello'

Template literals also work with infer for parsing:

type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${string}:${infer Last}`
? Last
: never;
type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

That pattern is how typed routers give you autocomplete on params.userId.

TypeScript ships these in lib.es5.d.ts; they’re globally available.

interface User {
id: number;
name: string;
email?: string;
}
type A = Partial<User>;
// { id?: number; name?: string; email?: string }
type B = Required<User>;
// { id: number; name: string; email: string }
type C = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email?: string }
type D = Pick<User, 'id' | 'name'>;
// { id: number; name: string }
type E = Omit<User, 'email'>;
// { id: number; name: string }
type F = Record<'admin' | 'guest', string[]>;
// { admin: string[]; guest: string[] }

Practical uses:

// A create payload: server generates the id.
type CreateUser = Omit<User, 'id'>;
// A patch payload: every field optional, id required.
type UpdateUser = Partial<Omit<User, 'id'>> & Pick<User, 'id'>;
function update(patch: UpdateUser) {}
update({ id: 1, name: 'Ada' }); // fine
update({ name: 'Ada' }); // error: Property 'id' is missing

Also note Partial and Readonly are shallow — nested objects are untouched.

type Status = 'idle' | 'loading' | 'success' | 'error';
type A = Exclude<Status, 'error'>; // 'idle' | 'loading' | 'success'
type B = Extract<Status, 'success' | 'error'>; // 'success' | 'error'
type C = NonNullable<string | null | undefined>; // string

Exclude<T, U> removes members of T assignable to U; Extract<T, U> keeps them. Both are one-line distributive conditionals:

type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

Extract is handy for filtering a discriminated union to one variant:

type Action =
| { type: 'add'; value: number }
| { type: 'remove'; id: string }
| { type: 'clear' };
type AddAction = Extract<Action, { type: 'add' }>; // { type: 'add'; value: number }
function createUser(name: string, age: number) {
return { id: 1, name, age };
}
type A = ReturnType<typeof createUser>; // { id: number; name: string; age: number }
type B = Parameters<typeof createUser>; // [name: string, age: number]
type C = Parameters<typeof createUser>[0]; // string
class Service {
constructor(public url: string, public timeout: number) {}
}
type D = ConstructorParameters<typeof Service>; // [url: string, timeout: number]
type E = InstanceType<typeof Service>; // Service

Parameters returning a tuple means you can forward arguments with full type safety:

function withLogging<F extends (...args: any[]) => any>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => {
console.log('calling', fn.name, args);
return fn(...args);
};
}
const loggedCreate = withLogging(createUser);
loggedCreate('Ada', 36); // fully typed; returns { id: number; name: string; age: number }
loggedCreate('Ada'); // error: Expected 2 arguments, but got 1

Added in TypeScript 4.5. It unwraps promises recursively, matching await’s real behaviour.

type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // number
type C = Awaited<string>; // string
async function load() {
return { id: 1 };
}
type Loaded = Awaited<ReturnType<typeof load>>; // { id: number }

Awaited is the right way to get the resolved type of an async function — ReturnType alone gives you the Promise<...>.

type A = ThisParameterType<(this: Window, n: number) => void>; // Window
type B = OmitThisParameter<(this: Window, n: number) => void>; // (n: number) => void
type C = NoInfer<string>; // TS 5.4+: blocks inference at this position
type D = Uppercase<'abc'>; // string intrinsics, listed above

Added in TypeScript 4.9. expr satisfies T checks that expr is assignable to T without widening the inferred type to T.

The problem it solves:

type Palette = Record<string, string | [number, number, number]>;
// With an annotation, you lose the specifics.
const colours: Palette = {
red: [255, 0, 0],
green: '#00ff00',
};
colours.green.toUpperCase(); // error: Property 'toUpperCase' does not exist on
// type 'string | [number, number, number]'
colours.blue; // no error — the index signature allows any key
// With `satisfies`, you get both the check and the precise type.
const colours = {
red: [255, 0, 0],
green: '#00ff00',
} satisfies Palette;
colours.green.toUpperCase(); // fine — green is `string`
colours.red[0]; // fine — red is number[]
colours.blue; // error: Property 'blue' does not exist

satisfies also catches typos that a bare object literal wouldn’t:

const config = {
host: 'localhost',
prot: 8080, // typo
} satisfies { host: string; port: number };
// error: Object literal may only specify known properties, and 'prot' does not exist

Rules of thumb:

Goal Use
Verify a value and keep its narrow type satisfies T
Verify a value and widen it to the contract : T annotation
Override the compiler with no check as T (avoid)

A const assertion stops all literal widening and makes everything readonly, recursively.

const a = { x: 1, mode: 'dark' };
// { x: number; mode: string }
const b = { x: 1, mode: 'dark' } as const;
// { readonly x: 1; readonly mode: 'dark' }
const c = [1, 2, 3]; // number[]
const d = [1, 2, 3] as const; // readonly [1, 2, 3]

The two workhorse patterns:

// 1. Derive a union from an array of values.
const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const;
type Method = (typeof METHODS)[number]; // 'GET' | 'POST' | 'PUT' | 'DELETE'
for (const m of METHODS) { /* m: Method */ }
// 2. An enum replacement with runtime values.
const Status = { Active: 'active', Banned: 'banned' } as const;
type Status = (typeof Status)[keyof typeof Status]; // 'active' | 'banned'

as const applies to literal expressions only — object literals, array literals, and primitive literals. You can’t write as const on a function call result or a variable reference.

Combining as const with satisfies gives you the strongest form: checked and narrow.

const routes = {
home: '/',
user: '/users/:id',
} as const satisfies Record<string, `/${string}`>;
type RoutePath = (typeof routes)[keyof typeof routes]; // '/' | '/users/:id'
  • keyof gives key unions, typeof lifts a value into a type, T[K] reads a property type.
  • Conditional types distribute over unions when the checked type is a naked type parameter; wrap in [ ] to stop it.
  • infer captures a matched type — the basis of ReturnType, Parameters, and tuple surgery.
  • Mapped types iterate keys; -readonly/-? remove modifiers; as remaps keys, and mapping to never drops them.
  • Template literal types build and parse strings; watch the combinatorial explosion.
  • Learn Partial, Pick, Omit, Record, Exclude, Extract, ReturnType, Parameters, and Awaited — they cover most day-to-day needs. Omit doesn’t validate its keys.
  • satisfies checks without widening; as const freezes literals; as const satisfies T gives you both.