DEV Community

Kai Thorne
Kai Thorne

Posted on

TypeScript Utility Types: How They Work (Not Just What They Do)

Every TypeScript developer uses Pick, Omit, Partial, and Record. But ask them how Omit is actually defined, and you'll get blank stares half the time.

That's not a criticism — it means the abstractions are working. But when you understand how these types are built, three things happen:

  1. You stop guessing which utility to use
  2. You can combine them to solve real problems
  3. You can write your own when the built-ins aren't enough

Let's walk through every built-in utility type from the inside out.


The Engine: Mapped Types and Conditional Types

Before we touch a single utility, you need two mental models.

Mapped types transform an object type by iterating over its keys:

// Takes an object type T, returns a new type with same keys but values as strings
type Stringify<T> = {
  [K in keyof T]: string
}

// Usage:
type User = { id: number; name: string; email: string }
type StringUser = Stringify<User>
// { id: string; name: string; email: string }
Enter fullscreen mode Exit fullscreen mode

Conditional types select between two types based on a condition:

type IsString<T> = T extends string ? true : false

type A = IsString<"hello"> // true
type B = IsString<42>      // false
Enter fullscreen mode Exit fullscreen mode

Every utility type in this article is built from these two primitives (plus keyof, typeof, and indexing). No magic.


1. Partial<T> — Make Every Property Optional

What it does: Takes a type and returns a new type where every property is optional.

How it works:

// Real definition in lib.es5.d.ts:
type Partial<T> = {
  [P in keyof T]?: T[P]
}
Enter fullscreen mode Exit fullscreen mode

The ? modifier is what makes each property optional. That's it — a single mapped type with an optional marker.

Real-world use:

interface UserConfig {
  theme: "light" | "dark"
  fontSize: number
  notifications: boolean
}

function applyConfig(updates: Partial<UserConfig>) {
  // Merge incoming partial updates with current config
  return { ...currentConfig, ...updates }
}

// Usage:
applyConfig({ theme: "dark" })
applyConfig({ fontSize: 14, notifications: false })
applyConfig({}) // Also valid — no changes
Enter fullscreen mode Exit fullscreen mode

This is the most common argument type for PATCH endpoints and setState-style updates.


2. Required<T> — Make Every Property Mandatory

What it does: The inverse of Partial. Every optional property becomes required.

How it works:

type Required<T> = {
  [P in keyof T]-?: T[P]
}
Enter fullscreen mode Exit fullscreen mode

The -? syntax removes the optional modifier. The - prefix strips modifiers instead of adding them.

Real-world use:

interface DraftPost {
  title?: string
  body?: string
  tags?: string[]
}

function publishPost(post: Required<DraftPost>) {
  // All fields must be filled in before publishing
  // publishPost({ title: "Hi" }) // ❌ Error — body and tags required
}
Enter fullscreen mode Exit fullscreen mode

3. Readonly<T> — Lock Down Properties

What it does: Marks every property as readonly.

How it works:

type Readonly<T> = {
  readonly [P in keyof T]: T[P]
}
Enter fullscreen mode Exit fullscreen mode

Real-world use:

function freezeConfig<T extends object>(config: T): Readonly<T> {
  return Object.freeze(config)
}

const APP_CONFIG = freezeConfig({
  apiUrl: "https://api.example.com",
  timeout: 5000,
})

// APP_CONFIG.apiUrl = "https://evil.com"  // ❌ Cannot assign to readonly
Enter fullscreen mode Exit fullscreen mode

Combine with Partial for the classic "configuration that can be partially set once" pattern:

type ImmutableConfig<T> = Readonly<Partial<T>>
Enter fullscreen mode Exit fullscreen mode

4. Pick<T, K> — Select Specific Keys

What it does: Creates a type with only the keys you specify from T.

How it works:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P]
}
Enter fullscreen mode Exit fullscreen mode

The constraint K extends keyof T ensures you can only pick keys that actually exist on T. TypeScript catches typos at compile time.

Real-world use:

interface User {
  id: string
  name: string
  email: string
  passwordHash: string
  ssn: string
  role: "admin" | "user"
}

// Public profile — never expose sensitive fields
type PublicUser = Pick<User, "id" | "name" | "email" | "role">

function getPublicProfile(user: User): PublicUser {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    role: user.role,
    // passwordHash and ssn are simply not returned
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Record<K, T> — Build Object Types from Scratch

What it does: Creates an object type where keys are type K and values are type T.

How it works:

type Record<K extends keyof any, T> = {
  [P in K]: T
}
Enter fullscreen mode Exit fullscreen mode

keyof any is string | number | symbol — the set of valid JavaScript object keys.

Real-world use — mapping enums to data:

type HttpStatus = 200 | 201 | 400 | 401 | 500

type StatusMessages = Record<HttpStatus, string>

const messages: StatusMessages = {
  200: "OK",
  201: "Created",
  400: "Bad Request",
  401: "Unauthorized",
  500: "Internal Server Error",
}
Enter fullscreen mode Exit fullscreen mode

TypeScript will enforce that you include every key in the union:

const badMessages: StatusMessages = {
  200: "OK",
  // ❌ Error: 201, 400, 401, 500 are missing
}
Enter fullscreen mode Exit fullscreen mode

Key pattern — dictionaries:

type UserMap = Record<string, User>

const users: UserMap = {}
users["abc123"] = { id: "abc123", name: "Alice", email: "alice@example.com" }
Enter fullscreen mode Exit fullscreen mode

But Record<string, T> is a dictionary — it accepts any string key. For stricter mappings, use a union type as K.


6. Exclude<T, U> — Remove from a Union

What it works on: Union types (not object types).

How it works:

type Exclude<T, U> = T extends U ? never : T
Enter fullscreen mode Exit fullscreen mode

This distributes over the union T. For each member of T:

  • If it extends U, it becomes never (removed)
  • Otherwise, it stays
type Shape = "circle" | "square" | "triangle" | "rectangle"

// Remove 'triangle' from the union
type Polygon = Exclude<Shape, "triangle">
// "circle" | "square" | "rectangle"

// Remove multiple
type NoCircles = Exclude<Shape, "circle" | "square">
// "triangle" | "rectangle"
Enter fullscreen mode Exit fullscreen mode

The distributive property is key here. Exclude<string | number | boolean, string | number> evaluates as:

(string extends string | number ? never : string)   never
| (number extends string | number ? never : number)  never
| (boolean extends string | number ? never : boolean)  boolean
// Result: boolean
Enter fullscreen mode Exit fullscreen mode

7. Extract<T, U> — Keep Only Matching Union Members

What it does: The inverse of Exclude — keeps only the members of T that are assignable to U.

How it works:

type Extract<T, U> = T extends U ? T : never
Enter fullscreen mode Exit fullscreen mode

Real-world use — event type narrowing:

type AllEvents =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "focus" }
  | { type: "blur" }

// Get only the event types that have a specific shape
type ClickEvents = Extract<AllEvents, { type: "click" }>
// { type: "click"; x: number; y: number }

type InputEvents = Extract<AllEvents, { type: "keypress" | "focus" }>
// { type: "keypress"; key: string } | { type: "focus" }
Enter fullscreen mode Exit fullscreen mode

8. Omit<T, K> — Remove Specific Keys

What it does: The inverse of Pick — returns a type with specific keys removed.

How it works:

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
Enter fullscreen mode Exit fullscreen mode

This is the most elegant composition of utility types. It:

  1. Gets all keys of T with keyof T
  2. Removes K from that union with Exclude
  3. Picks the remaining keys with Pick

Real-world use — remove internal fields before serialization:

interface InternalTodo {
  id: string
  title: string
  completed: boolean
  _version: number
  _syncStatus: "pending" | "synced"
  _createdBy: string
}

// Public API — strip internal fields
type APITodo = Omit<InternalTodo, "_version" | "_syncStatus" | "_createdBy">
// { id: string; title: string; completed: boolean }
Enter fullscreen mode Exit fullscreen mode

Note: In TypeScript 5.x+, Omit was updated so K no longer needs to extend keyof T — you can pass keys that don't exist on T without error. This makes it safer for generic code where you're not sure which keys exist.


Bonus: The Conditional Utility Types

These use conditional types with infer to extract information from function and constructor types.

Parameters<T> — Get Function Parameter Types

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never
Enter fullscreen mode Exit fullscreen mode
function createUser(name: string, age: number, email: string) {
  return { name, age, email }
}

type CreateUserArgs = Parameters<typeof createUser>
// [name: string, age: number, email: string]
Enter fullscreen mode Exit fullscreen mode

ReturnType<T> — Get Function Return Type

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
Enter fullscreen mode Exit fullscreen mode
type CreateUserReturn = ReturnType<typeof createUser>
// { name: string; age: number; email: string }
Enter fullscreen mode Exit fullscreen mode

ConstructorParameters<T> and InstanceType<T>

These work on constructor signatures:

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

type DBParams = ConstructorParameters<typeof Database>
// [host: string, port: number]

type DBInstance = InstanceType<typeof Database>
// Database
Enter fullscreen mode Exit fullscreen mode

NonNullable<T> — Remove Null and Undefined

type NonNullable<T> = T extends null | undefined ? never : T
Enter fullscreen mode Exit fullscreen mode
type Maybe = string | null | undefined
type Definitely = NonNullable<Maybe>
// string
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns

Pattern 1: Selective Partial (Deep Partial Update)

The built-in Partial is shallow. For nested updates, you need a recursive version:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

interface NestedConfig {
  server: { host: string; port: number }
  database: { url: string; poolSize: number }
}

function patchConfig(update: DeepPartial<NestedConfig>) {
  // patchConfig({ server: { host: "new-host" } })  // ✅ Works deeply
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Pick by Value Type

Sometimes you want to pick keys based on what value type they hold, not by name:

type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K]
}

interface Entity {
  id: string
  name: string
  createdAt: Date
  updatedAt: Date
  metadata: Record<string, unknown>
}

type DateFields = PickByValue<Entity, Date>
// { createdAt: Date; updatedAt: Date }
Enter fullscreen mode Exit fullscreen mode

This uses key remapping via as (TypeScript 4.1+), which lets you filter keys inside the mapped type.

Pattern 3: Make Specific Keys Required

Required<T> makes everything required. What if you only need a subset?

type WithRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>

interface Draft {
  title?: string
  body?: string
  tags?: string[]
}

type DraftWithTitle = WithRequired<Draft, "title">
// title is required; body and tags stay optional
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Substitute Property Types

Need to change the type of one property without touching the rest?

type Override<T, R extends Partial<Record<keyof T, unknown>>> = {
  [P in keyof T]: P extends keyof R ? R[P] : T[P]
}

interface Feature {
  name: string
  enabled: boolean
  version: number
}

// Change `version` from number to string
type FeatureUI = Override<Feature, { version: string }>
// { name: string; enabled: boolean; version: string }
Enter fullscreen mode Exit fullscreen mode

Common Gotchas

🚨 Omit with unions behaves unexpectedly

When T is a union, Omit distributes over it:

type Result =
  | { status: "success"; data: string }
  | { status: "error"; message: string }

type WithoutStatus = Omit<Result, "status">
// { data: string } | { message: string }
// Both branches survive — only the status key is removed from each
Enter fullscreen mode Exit fullscreen mode

🚨 Readonly is shallow

interface User {
  name: string
  address: { city: string; zip: string }
}

const user: Readonly<User> = { name: "Alice", address: { city: "NYC", zip: "10001" } }
// user.name = "Bob"         // ❌ Error
// user.address.city = "LA"  // ✅ No error — address is still mutable!
Enter fullscreen mode Exit fullscreen mode

For truly immutable types, you need a recursive DeepReadonly.

🚨 Partial from the right place

If you have a union of objects, Partial distributes:

type State = { type: "loading" } | { type: "loaded"; data: string }
type PartialState = Partial<State>
// { type?: "loading" } | { type?: "loaded"; data?: string }
Enter fullscreen mode Exit fullscreen mode

This is rarely what you want. Map over the union explicitly instead.


Summary

Utility What It Does Built From
Partial<T> Makes all props optional Mapped type with ?
Required<T> Makes all props required Mapped type with -?
Readonly<T> Makes all props readonly Mapped type with readonly
Pick<T, K> Selects specific keys Mapped type constrained to K
Record<K, T> Creates key-value type Mapped type over K
Exclude<T, U> Removes from union Conditional (distributes)
Extract<T, U> Keeps matching union members Conditional (distributes)
Omit<T, K> Removes specific keys Pick + Exclude
Parameters<T> Gets function params infer in conditional
ReturnType<T> Gets function return infer in conditional
NonNullable<T> Removes null/undefined Conditional

The built-in utility types aren't magic — they're just mapped types and conditional types with clear names. Once you understand that, you can read their definitions in your editor (Cmd+Click in VS Code) and even write your own.

And when the built-ins aren't enough, you now have the primitives to build exactly what you need.



Level up your development workflow

If this article helped you think more clearly about TypeScript, here are some resources I've put together:

Published by Kai Thorne. I write about TypeScript, Python, and developer workflows.

Top comments (0)