Emmanuel Akinfulubi

Lesson 16 of 1818 min read

The Utility Types

A dozen built-in transformations that stop you from writing the same shape twice. Partial, Pick, Omit, Record and the rest.

By the end: Derive the type you need from one you already have.

TypeScript ships about a dozen built-in type transformations. They exist so you never write the same shape twice, and knowing them is mostly a matter of recognition rather than understanding — each one does something small and obvious once you have seen it.

Lesson 17 shows how they are implemented. This lesson is about using them, which is the part you need every day.

Partial, Required and Readonly

Three transformations over every property

interface User {
  id: number;
  name: string;
  email?: string;
}

// Every property optional. The classic use is an update payload.
type UserPatch = Partial<User>;

// Every property required, including ones that were optional.
type CompleteUser = Required<User>;

// Every property readonly.
type FrozenUser = Readonly<User>;

const patch: UserPatch = { name: 'Ada' };
const complete: CompleteUser = { id: 1, name: 'Ada', email: 'ada@example.com' };
const frozen: FrozenUser = { id: 2, name: 'Grace' };

function applyPatch(user: User, changes: Partial<User>): User {
  return { ...user, ...changes };
}

console.log(applyPatch(complete, patch));
console.log(complete.email.toUpperCase());
console.log(frozen.name);

Output


      

Readonly is checked

TS2540
interface User {
  id: number;
  name: string;
}

const user: Readonly<User> = { id: 1, name: 'Ada' };

user.name = 'Grace';

console.log(user);

What the compiler says

error TS2540: Cannot assign to 'name' because it is a read-only property.

Partial does not reach into nested objects

TS2741
interface Settings {
  theme: string;
  layout: { columns: number; gutter: number };
}

// `layout` itself is now optional. Its contents are not.
const patch: Partial<Settings> = { layout: { columns: 3 } };

console.log(patch);

What the compiler says

error TS2741: Property 'gutter' is missing in type '{ columns: number; }' but required in type '{ columns: number; gutter: number; }'.

Pick and Omit

Selecting and excluding properties

interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

// Keep only what you name.
type PublicUser = Pick<User, 'id' | 'name'>;

// Keep everything except what you name.
type SafeUser = Omit<User, 'passwordHash'>;

// They compose, which is where the value is.
type UserSummary = Pick<SafeUser, 'id' | 'name' | 'email'>;

const publicUser: PublicUser = { id: 1, name: 'Ada' };
const safe: SafeUser = {
  id: 1,
  name: 'Ada',
  email: 'ada@example.com',
  createdAt: new Date('2026-01-01'),
};
const summary: UserSummary = { id: 1, name: 'Ada', email: 'ada@example.com' };

console.log(publicUser);
console.log(safe.email, safe.createdAt.getUTCFullYear());
console.log(summary);

Output


      

Pick catches a bad key

TS2344
interface User {
  id: number;
  name: string;
}

type Bad = Pick<User, 'nmae'>;

const value: Bad = {} as Bad;
console.log(value);

What the compiler says

error TS2344: Type '"nmae"' does not satisfy the constraint 'keyof User'.

Omit silently accepts a key that is not there

interface User {
  id: number;
  name: string;
}

// A typo. No error, and the resulting type still has both properties, so the
// mistake surfaces later as "why is passwordHash still in my response".
type Oops = Omit<User, 'nmae'>;

const value: Oops = { id: 1, name: 'Ada' };
console.log('both properties survived the typo:', Object.keys(value));

// A stricter Omit, constrained to real keys. Worth having in a project.
type StrictOmit<T, K extends keyof T> = Omit<T, K>;

type Safe = StrictOmit<User, 'name'>;
const safe: Safe = { id: 1 };
console.log('strict version:', safe);

Output


      

Record

Building an object type from keys and a value type

// Any string key, values are numbers.
type Counts = Record<string, number>;

// A constrained key set, which is where Record does real work: the compiler
// now insists every member is present.
type Level = 'debug' | 'info' | 'error';
type Colours = Record<Level, string>;

const counts: Counts = { a: 1, b: 2 };
const colours: Colours = { debug: 'grey', info: 'blue', error: 'red' };

console.log(counts['a'] ?? 0);
console.log(colours.error);

// Values can be any type, including functions.
type Handlers = Record<Level, (message: string) => string>;

const handlers: Handlers = {
  debug: (m) => `. ${m}`,
  info: (m) => `- ${m}`,
  error: (m) => `! ${m}`,
};

for (const level of ['debug', 'info', 'error'] as const) {
  console.log(handlers[level]('something happened'));
}

Output


      

A Record over a union demands every member

TS2741
type Level = 'debug' | 'info' | 'error';

// Missing `error`. This is the exhaustiveness check from lesson 04, applied to
// an object rather than a switch — and it is why Record over a union is such a
// useful pattern.
const colours: Record<Level, string> = {
  debug: 'grey',
  info: 'blue',
};

console.log(colours);

What the compiler says

error TS2741: Property 'error' is missing in type '{ debug: string; info: string; }' but required in type 'Record<Level, string>'.

The union transformations

Exclude, Extract and NonNullable

type Level = 'debug' | 'info' | 'warn' | 'error';

// Remove members from a union.
type Quiet = Exclude<Level, 'warn' | 'error'>;       // 'debug' | 'info'

// Keep only the members that match.
type Loud = Extract<Level, 'warn' | 'error'>;        // 'warn' | 'error'

// Remove null and undefined.
type Maybe = string | null | undefined;
type Definitely = NonNullable<Maybe>;                // string

const quiet: Quiet = 'debug';
const loud: Loud = 'error';
const definite: Definitely = 'text';

console.log(quiet, loud, definite);

// Extract is genuinely useful on object unions: pull out one member by its tag.
type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'key'; code: string }
  | { kind: 'scroll'; delta: number };

type KeyEvent = Extract<Event, { kind: 'key' }>;

function describeKey(event: KeyEvent): string {
  return `key ${event.code}`;
}

console.log(describeKey({ kind: 'key', code: 'Enter' }));

Output


      

Function and constructor transformations

ReturnType, Parameters and Awaited

function findUser(id: number, includeDeleted = false): { id: number; name: string } | undefined {
  return id === 1 ? { id: 1, name: 'Ada' } : undefined;
}

async function fetchTitle(): Promise<string> {
  await Promise.resolve();
  return 'Document';
}

// The return type, without writing it out.
type FoundUser = ReturnType<typeof findUser>;         // { ... } | undefined

// The parameters, as a tuple.
type FindArgs = Parameters<typeof findUser>;          // [number, boolean?]

// Unwrap a Promise.
type Title = Awaited<ReturnType<typeof fetchTitle>>;  // string

// And these compose, which is the usual reason to reach for them.
type DefiniteUser = NonNullable<ReturnType<typeof findUser>>;

const found: FoundUser = { id: 1, name: 'Ada' };
const args: FindArgs = [1, true];
const definite: DefiniteUser = { id: 1, name: 'Ada' };

console.log(found?.name, args[0], definite.name);
console.log('title type is a plain string:', await fetchTitle());

// Spreading a Parameters tuple back into the call is a neat trick for wrappers.
function logged(...params: FindArgs): FoundUser {
  console.log('calling with', params);
  return findUser(...params);
}

console.log(logged(1)?.name);

Output


      

InstanceType and ConstructorParameters

class Connection {
  constructor(
    public readonly host: string,
    public readonly port: number,
  ) {}

  describe(): string {
    return `${this.host}:${this.port}`;
  }
}

// The type of an instance, from the class value.
type Conn = InstanceType<typeof Connection>;

// The constructor's arguments, as a tuple.
type ConnArgs = ConstructorParameters<typeof Connection>;

const conn: Conn = new Connection('localhost', 5432);
const args: ConnArgs = ['db.internal', 6543];

console.log(conn.describe());
console.log(new Connection(...args).describe());

// This is how you type a factory that takes a class and builds one.
function build<T extends new (...args: never[]) => unknown>(
  Ctor: T,
  ...args: ConstructorParameters<T>
): InstanceType<T> {
  return new Ctor(...(args as never[])) as InstanceType<T>;
}

console.log(build(Connection, 'example.org', 443).describe());

Output


      

The string transformations

Uppercase, Lowercase, Capitalize, Uncapitalize

type Method = 'get' | 'post' | 'delete';

type Loud = Uppercase<Method>;                 // 'GET' | 'POST' | 'DELETE'
type Titled = Capitalize<Method>;              // 'Get' | 'Post' | 'Delete'

const loud: Loud = 'POST';
const titled: Titled = 'Delete';

console.log(loud, titled);

// They combine with template literal types to derive names mechanically.
type Handler = `handle${Capitalize<Method>}`;   // 'handleGet' | 'handlePost' | ...

const handlerName: Handler = 'handlePost';
console.log(handlerName);

// Which lets you demand a handler per method, checked.
type Handlers = { [K in Method as `handle${Capitalize<K>}`]: () => string };

const handlers: Handlers = {
  handleGet: () => 'reading',
  handlePost: () => 'creating',
  handleDelete: () => 'removing',
};

console.log(handlers.handleGet(), handlers.handleDelete());

Output


      

Putting them together

A realistic set of derived API types from one source of truth

// The single definition. Everything else follows from it.
interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
  lastSeenAt?: Date;
}

// What the API returns: no secrets, dates as strings over the wire.
type UserResponse = Omit<User, 'passwordHash' | 'createdAt' | 'lastSeenAt'> & {
  createdAt: string;
  lastSeenAt?: string;
};

// What creating one requires: no id, no timestamps, plus a plain password.
type CreateUser = Pick<User, 'name' | 'email'> & { password: string };

// What updating one allows: any subset of the creatable fields.
type UpdateUser = Partial<CreateUser>;

// A read-only view for rendering.
type UserView = Readonly<UserResponse>;

function toResponse(user: User): UserResponse {
  const base = {
    id: user.id,
    name: user.name,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  };
  return user.lastSeenAt === undefined
    ? base
    : { ...base, lastSeenAt: user.lastSeenAt.toISOString() };
}

const stored: User = {
  id: 1,
  name: 'Ada',
  email: 'ada@example.com',
  passwordHash: 'x'.repeat(60),
  createdAt: new Date('2026-01-01T00:00:00Z'),
};

const response: UserResponse = toResponse(stored);
const create: CreateUser = { name: 'Grace', email: 'grace@example.com', password: 'secret' };
const update: UpdateUser = { email: 'new@example.com' };
const view: UserView = response;

console.log(response);
console.log(create.name, Object.keys(update));
console.log('no passwordHash in the response:', !('passwordHash' in response));
console.log('view is the same data, read-only:', view.name);

Output


      

The full list, briefly

Everything worth knowing, in one panel

interface Example {
  a: string;
  b?: number;
  readonly c: boolean;
}

type Fn = (x: number, y: string) => Promise<boolean>;
class Cls { constructor(public n: number) {} }

// Over object properties.
type T1 = Partial<Example>;
type T2 = Required<Example>;
type T3 = Readonly<Example>;
type T4 = Pick<Example, 'a' | 'b'>;
type T5 = Omit<Example, 'c'>;
type T6 = Record<'x' | 'y', number>;

// Over unions.
type T7 = Exclude<'a' | 'b' | 'c', 'c'>;
type T8 = Extract<'a' | 'b' | 'c', 'a' | 'z'>;
type T9 = NonNullable<string | null | undefined>;

// Over functions and classes.
type T10 = ReturnType<Fn>;
type T11 = Parameters<Fn>;
type T12 = Awaited<ReturnType<Fn>>;
type T13 = InstanceType<typeof Cls>;
type T14 = ConstructorParameters<typeof Cls>;
type T15 = ThisParameterType<(this: Example, n: number) => void>;

// Over strings.
type T16 = Uppercase<'get'>;
type T17 = Lowercase<'GET'>;
type T18 = Capitalize<'get'>;
type T19 = Uncapitalize<'Get'>;

// One value of each, to prove they resolve to what you would expect.
const values = {
  partial: { a: 'x' } satisfies T1,
  picked: { a: 'x' } satisfies T4,
  omitted: { a: 'x' } satisfies T5,
  record: { x: 1, y: 2 } satisfies T6,
  excluded: 'a' satisfies T7,
  extracted: 'a' satisfies T8,
  nonNull: 'x' satisfies T9,
  returned: true satisfies T12,
  params: [1, 'x'] satisfies T11,
  instance: new Cls(1) satisfies T13,
  ctorArgs: [2] satisfies T14,
  upper: 'GET' satisfies T16,
  capital: 'Get' satisfies T18,
};

console.log(values.record, values.excluded, values.upper);
console.log(values.instance.n, values.ctorArgs, values.params);
console.log('every one of these resolved at compile time and vanished');

const t2: T2 = { a: 'x', b: 1, c: true };
const t3: T3 = { a: 'x', c: false };
const t10: T10 = Promise.resolve(true);
const t15: T15 = { a: 'x', c: true };
const t17: T17 = 'get';
const t19: T19 = 'get';
console.log(t2.b, t3.c, await t10, t15.a, t17, t19);

Output


      

Your turn

  1. Write a Product interface with five fields including a secret one. Derive a public response type with Omit and a creation type with Pick.
  2. Write an update function taking Partial<Product>. Then add a nested object to Product and confirm Partial does not make its contents optional.
  3. Make a typo in a Pick and read the error. Make the same typo in an Omit and note that there is no error at all.
  4. Write type Level as a union of four values and a Record<Level, string> with only three entries. Read the error. Then add a fifth level and watch the same error return.
  5. Use Extract to name one member of a discriminated union, and write a function taking exactly that member.
  6. Write a function, then derive its parameter tuple with Parameters and write a wrapper that logs its arguments and forwards them with a spread.

Recap

  • Partial, Required and Readonly transform every property. All three are shallow — nested objects are untouched.
  • Pick names what to keep and is checked against real keys; Omit names what to drop and silently accepts a typo. Prefer Pick, or write a constrained StrictOmit.
  • Record<Union, V> demands an entry for every union member, which gives you an exhaustiveness check on an object for free.
  • Exclude and Extract operate on unions; Extract<Event, { kind: 'key' }> is how you name one member of a discriminated union without repeating it.
  • ReturnType, Parameters and Awaited derive from functions. Useful for other people's code; a sign of a missing named interface in your own.
  • Derive rather than duplicate. One interface plus five utility types beats five hand-written interfaces that drift apart.

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.