Modern Web · Typescript

TypeScript Utility Types — A Field Guide

A practical tour of TypeScript's built-in utility types — Partial, Pick, Omit, Record, ReturnType, Parameters, Awaited — and the specific case, Omit on a discriminated union, where you need to write your own instead.

John Kihiu12 min read

Most of the type-level work I do day to day doesn't need conditional types, mapped type remapping, or template literal wizardry. It needs Partial, Pick, Omit, and a handful of others that ship in lib.es5.d.ts and have been stable for years. They're boring, every TypeScript developer already knows what they do, and that's exactly the point — a type only your team's resident type-gymnast can read is a liability, not a flex. This is a rundown of the utility types I reach for constantly, and the one well-known case, Omit on a discriminated union, where reaching for the built-in quietly breaks the thing you actually wanted.

The shape changers: Partial, Required, Pick, Omit

Partial<T> makes every property optional, which is what a PATCH-style update function wants — you're allowed to send only the fields you're changing. Required<T> does the reverse: it strips the optionality, useful once you've merged a partial update onto defaults and want the compiler to confirm nothing is missing anymore. Pick<T, K> and Omit<T, K> are the pair I use most — carve a narrower view out of a bigger type instead of hand-declaring a near-duplicate interface that drifts out of sync the moment someone adds a field upstream.

TypeScript
interface User {
  id: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
  role: 'admin' | 'member';
}

// what the API is allowed to return to the client
type PublicUser = Omit<User, 'passwordHash'>;

// what a signup form collects before we generate the rest
type SignupInput = Pick<User, 'email'> & { password: string };

// a PATCH /users/:id body — any subset of these fields
type UserUpdate = Partial<Pick<User, 'email' | 'role'>>;

The pattern worth internalizing is that Pick and Omit compose. You rarely want the whole record and you rarely want to omit or pick in isolation — you want a narrow slice of an already-narrow slice, and stacking them keeps the source of truth as the one interface.

Record: mapping a key set to a shape

Record<K, V> says "an object whose keys are exactly K, each mapping to a value of type V." It's the right tool the moment you have a fixed, known set of keys — a union of string literals, an enum — and you want the compiler to enforce that every key is handled and no stray key sneaks in.

TypeScript
type Plan = 'free' | 'pro' | 'enterprise';

const monthlyPriceCents: Record<Plan, number> = {
  free: 0,
  pro: 2900,
  enterprise: 19900,
  // omitting a plan, or adding one that isn't in Plan, is a compile error
};
Record vs an index signature

A plain { [key: string]: number } accepts any string key and gives you no exhaustiveness check. Record<Plan, number> with a literal union for K forces every member of the union to be present — closer to what you actually want when the key set is closed and known ahead of time.

Pulling types out of functions: ReturnType, Parameters, Awaited

ReturnType<T> and Parameters<T> exist for the case where a function's shape is defined once and you don't want to redeclare it as a separate interface that can drift out of sync. This comes up constantly with third-party libraries and legacy code where you don't own the function signature but you do need a type for its return value or its argument tuple.

Awaited<T>, added in TypeScript 4.5, unwraps a Promise — including nested and thenable-wrapped promises — recursively, which ReturnType alone can't do for an async function. Before 4.5 you'd end up with Promise<Promise<User>> shapes in edge cases involving thenables; Awaited was added specifically to model what await does at the type level.

TypeScript
async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as Promise<User>;
}

// the resolved value, not the Promise wrapper
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>; // User

// the argument tuple, useful for wrapping/proxying the function
type FetchUserArgs = Parameters<typeof fetchUser>; // [id: string]

Two smaller ones round this out. NonNullable<T> strips null and undefined from a union — handy after a manual guard that the compiler didn't narrow on its own. InstanceType<T> gets you the instance type from a class constructor's type, which shows up when you're storing a registry of class references and need the type of what new T() would produce.

Where Omit quietly breaks on unions

This is the gotcha that actually costs people time. Omit is defined as Pick<T, Exclude<keyof T, K>>. That definition works fine on an ordinary object type, but on a union of object types it does something most people don't expect: it takes keyof T of the whole union first, which collapses to only the keys common to every member, then omits from that flattened key set — and the result is no longer a discriminated union at all. You lose the narrowing behavior that made the discriminant useful in the first place.

TypeScript
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

// BROKEN: Omit flattens the union before removing keys.
// The result is { kind: 'circle' | 'square' } with no radius/side at all —
// switching on `kind` no longer narrows the other properties.
type ShapeWithoutKind = Omit<Shape, 'kind'>;

// FIX: a distributive version that applies Omit to each union member
// individually, preserving the discriminant-narrowing relationship.
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
  ? Omit<T, K>
  : never;

type Dimensions = DistributiveOmit<Shape, 'kind'>;
// { radius: number } | { side: number } — correct
Why the naive Omit even compiles

TypeScript doesn't error here because nothing is technically wrong — Omit did exactly what its definition says. The bug is silent: you get a type that type-checks fine right up until you try to use the discriminant to narrow the rest of the shape, and the properties you expected simply aren't there. It's worth reaching for DistributiveOmit by default anytime T might be a union, not just when you've already been bitten once.

Wrapping up

The built-in utility types cover the large majority of real cases cheaply, and because every TypeScript developer already knows Partial, Pick, and Omit on sight, using them is a readability win, not just a typing shortcut — reach for a hand-rolled mapped or conditional type only once you've confirmed the built-in doesn't fit. The one case worth memorizing as an exception is Omit on a union: because it's implemented via Pick<T, Exclude<keyof T, K>>, it flattens the union and silently destroys discriminated-union narrowing. A three-line DistributiveOmit using a naked conditional type (T extends unknown ? Omit<T, K> : never) fixes it, and it's worth keeping in a shared types file rather than rediscovering the bug in code review.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.