Ch. 2 · TypeScript

Build TypeScript's Utility Types From Scratch

Reimplement Partial, Pick, Omit, Exclude, ReturnType, Awaited and friends with mapped and conditional types, plus modifiers, key remapping and template literals.

~6 min readadvanced

Rebuilding the built-in utility types is a classic advanced TypeScript exercise, because every one of them is a few lines built on two ideas. Mapped types walk over the keys of a type and transform each property. Conditional types choose between types, distribute over unions and can pattern-match with infer. Once you can write the utilities yourself, their edge cases stop being surprising. Each version below has a My prefix so it doesn’t clash with the global, and for the cases shown it produces the same result as the built-in.

Mapped types and modifiers

A mapped type iterates over a union of keys with [K in ...] and builds a property for each one. T[K] looks up the original property type.

interface User {
  id: string;
  name: string;
  email?: string;
  readonly createdAt: Date;
}

type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

// Not built in, but a common follow-up question
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

type A = MyRequired<User>; // email: string, createdAt still readonly
type B = Mutable<User>; // createdAt writable, email still optional
TypeScript

The modifiers are ? and readonly. Writing them adds them (+? and +readonly are the explicit spellings of the same thing), and a - prefix removes them. -? also strips the undefined that optionality added, which is why email becomes plain string.

Notice that Mutable kept email optional. When a mapped type iterates over keyof T directly, it’s called homomorphic, and TypeScript preserves each property’s existing modifiers unless you change them. Homomorphic mapped types also map arrays and tuples to arrays and tuples, rather than to objects with numeric keys:

type T1 = MyPartial<[string, number]>; // [string?, number?]
type T2 = MyReadonly<string[]>; // readonly string[]
TypeScript

Exclude, Extract and NonNullable

These three work on unions rather than object properties, and all of them rely on distributive conditional types: a conditional on a naked type parameter runs once per union member.

type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;

type Status = "idle" | "loading" | "success" | "error";
type Settled = MyExclude<Status, "idle" | "loading">; // "success" | "error"
type Fns = MyExtract<string | number | (() => void), Function>; // () => void

type MyNonNullable<T> = T & {};
type Name = MyNonNullable<string | null | undefined>; // string
TypeScript

Walk through Settled out loud in an interview: each member is tested separately, "idle" and "loading" become never, and never disappears from a union, leaving "success" | "error". (Distribution is covered in more depth in TypeScript Generics, Explained Through Interview Questions.)

{} means “any value except null and undefined”, so intersecting with it removes the nullish members: null & {} is never, and string & {} is just string. Current TypeScript defines NonNullable this way; older versions used the conditional form T extends null | undefined ? never : T, which gives the same result here.

Pick, Record and Omit

type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyRecord<K extends keyof any, V> = { [P in K]: V };
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>;

type Preview = MyPick<User, "id" | "email">; // { id: string; email?: string }
type Flags = MyRecord<"admin" | "beta", boolean>; // { admin: boolean; beta: boolean }
type NoEmail = MyOmit<User, "email">; // { id; name; readonly createdAt }
TypeScript

keyof any is string | number | symbol, every type that can be a property key. Pick keeps modifiers because its keys are constrained to keyof T; Record has no source type, so it has none to keep.

Omit has two well-known quirks. First, its K is keyof any, not keyof T, so a typo like Omit<User, "emial"> compiles and removes nothing. A stricter version uses key remapping (explained below), where mapping a key to never drops it:

type StrictOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};
type Oops = StrictOmit<User, "emial">; // ❌ does not satisfy 'keyof User'
TypeScript

Second, Omit doesn’t distribute over unions, because keyof (A | B) contains only the keys common to both members.

type Shape =
  | { kind: "circle"; radius: number; id: string }
  | { kind: "square"; size: number; id: string };

type Broken = Omit<Shape, "id">; // { kind: "circle" | "square" }

type DistributiveOmit<T, K extends keyof any> =
  T extends unknown ? Omit<T, K> : never;
type Fixed = DistributiveOmit<Shape, "id">;
// { kind: "circle"; radius: number } | { kind: "square"; size: number }
TypeScript

Gotcha

Omit on a discriminated union silently throws away every member-specific property. If you’re building props for a component that wraps a union type, reach for a distributive version.

ReturnType and Parameters

Both use infer to pattern-match a function type and pull out one piece of it.

type MyReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

type MyParameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

function createUser(name: string, age?: number) {
  return { name, age, active: true };
}

type NewUser = MyReturnType<typeof createUser>;
// { name: string; age: number | undefined; active: boolean }
type Args = MyParameters<typeof createUser>; // [name: string, age?: number]

type Bad = MyReturnType<string>; // ❌ does not satisfy the constraint
TypeScript

These operate on types, so you need typeof to get the type of a function value. Parameters come back as a labeled tuple, optional elements included. For an overloaded function, inference uses the last signature: with format(value: string): string declared before format(value: number): number, ReturnType<typeof format> is number.

Interview tip

A good follow-up question is “why any in the constraint and not unknown?” Because parameters are contravariant: (x: string) => void is not assignable to (...args: unknown[]) => unknown, since a function that needs a string can’t accept anything. any (or never[]) accepts every function.

Awaited, simplified

await unwraps promises recursively, so a promise of a promise of a string gives you a string. A simplified Awaited is a recursive conditional type:

type MyAwaited<T> = T extends PromiseLike<infer V> ? MyAwaited<V> : T;

type A1 = MyAwaited<Promise<Promise<string>>>; // string
type A2 = MyAwaited<number>; // number
type A3 = MyAwaited<Promise<string> | number>; // string | number

async function loadUser() {
  return { id: "1" };
}
type Loaded = MyAwaited<ReturnType<typeof loadUser>>; // { id: string }
TypeScript

It matches PromiseLike rather than Promise because await works on any thenable, and it distributes over unions like any conditional on a naked type parameter.

Note

The real Awaited is more thorough. It accepts any object with a callable then method, infers the value from the first parameter of then’s callback, and special-cases null and undefined. The simplified version is what interviewers expect you to write.

Key remapping and template literals

An as clause in a mapped type lets you compute a new key for each property. Combined with template literal types, that covers most “generate an API from a type” questions.

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type PointGetters = Getters<{ x: number; label: string }>;
// { getX: () => number; getLabel: () => string }

type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};
type StringFields = PickByValue<User, string>; // { id: string; name: string }
TypeScript

string & K is there because keyof T can include number and symbol keys, and Capitalize only accepts strings. In StringFields, email is missing because an optional property’s type is string | undefined, which doesn’t extend string.

Template literal types expand unions into every combination, and infer can parse strings:

type Size = "sm" | "lg";
type Tone = "info" | "error";
type ButtonClass = `btn-${Size}-${Tone}`; // 4 combinations

type RouteParams<S extends string> =
  S extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : S extends `${string}:${infer Param}`
      ? Param
      : never;

type P = RouteParams<"/users/:userId/posts/:postId">; // "userId" | "postId"
TypeScript

The interview answer

“All the built-in utilities come from two features. Mapped types like Partial, Required, Readonly, Pick and Record iterate over keys with [K in keyof T] and use ?, readonly and the - prefix to add or remove modifiers; mapping over keyof T preserves existing modifiers. Conditional types handle the rest: Exclude and Extract are distributive conditionals over unions, and ReturnType, Parameters and Awaited use infer to pattern-match a function or promise type.

I’d also mention the sharp edges: Omit accepts keys that don’t exist and collapses discriminated unions, so I sometimes use a stricter or distributive version, and key remapping with as plus template literal types lets me derive things like getter names or route parameters from existing types.”

More in TypeScript

read ✓TypeScript · easy

type vs interface: When It Actually Matters

Declaration merging, extends vs intersections, what only type aliases can express, and the few differences that change behavior, with a clear rule of thumb.

~5 min readread →
read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc