TypeScript Utility Types: How They Work (Not Just What They Do)
DEV Community Grade 10 1h ago

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: You stop guessing which utility to use You can combine them to solve real problems 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 } 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 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 ] } 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 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 ] } 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 } 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 ] } 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 Combine with Partial for the classic "configuration that can be partially set once" pattern: type ImmutableConfig < T > = Readonly < Partial < T >> 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 ] } 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 } } 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 } 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 " , } TypeScript will enforce that you include every key in the union: const badMessages : StatusMessages = { 200 : " OK " , // ❌ Error: 201, 400, 401, 500 are missing } Key pattern — dictionaries: type UserMap = Record < string , User > const users : UserMap = {} users [ " abc123 " ] = { id : " abc123 " , name : " Alice " , email : " alice@example.com "

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: - You stop guessing which utility to use - You can combine them to solve real problems - 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 = { [K in keyof T]: string } // Usage: type User = { id: number; name: string; email: string } type StringUser = Stringify // { id: string; name: string; email: string } Conditional types select between two types based on a condition: type IsString = T extends string ? true : false type A = IsString // true type B = IsString // false Every utility type in this article is built from these two primitives (plus keyof , typeof , and indexing). No magic. 1. Partial — 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 = { [P in keyof T]?: T[P] } 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 ) { // Merge incoming partial updates with current config return { ...currentConfig, ...updates } } // Usage: applyConfig({ theme: "dark" }) applyConfig({ fontSize: 14, notifications: false }) applyConfig({}) // Also valid — no changes This is the most common argument type for PATCH endpoints and setState -style updates. 2. Required — Make Every Property Mandatory What it does: The inverse of Partial . Every optional property becomes required. How it works: type Required = { [P in keyof T]-?: T[P] } 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 ) { // All fields must be filled in before publishing // publishPost({ title: "Hi" }) // ❌ Error — body and tags required } 3. Readonly — Lock Down Properties What it does: Marks every property as readonly . How it works: type Readonly = { readonly [P in keyof T]: T[P] } Real-world use: function freezeConfig (config: T): Readonly { 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 Combine with Partial for the classic "configuration that can be partially set once" pattern: type ImmutableConfig = Readonly > 4. Pick — Select Specific Keys What it does: Creates a type with only the keys you specify from T . How it works: type Pick = { [P in K]: T[P] } 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 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 } } 5. Record — 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 = { [P in K]: T } 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 const messages: StatusMessages = { 200: "OK", 201: "Created", 400: "Bad Request", 401: "Unauthorized", 500: "Internal Server Error", } TypeScript will enforce that you include every key in the union: const badMessages: StatusMessages = { 200: "OK", // ❌ Error: 201, 400, 401, 500 are missing } Key pattern — dictionaries: type UserMap = Record const users: UserMap = {} users["abc123"] = { id: "abc123", name: "Alice", email: "alice@example.com" } But Record is a dictionary — it accepts any string key. For stricter mappings, use a union type as K . 6. Exclude — Remove from a Union What it works on: Union types (not object types). How it works: type Exclude = T extends U ? never : T This distributes over the union T . For each member of T : - If it extends U , it becomesnever (removed) - Otherwise, it stays type Shape = "circle" | "square" | "triangle" | "rectangle" // Remove 'triangle' from the union type Polygon = Exclude // "circle" | "square" | "rectangle" // Remove multiple type NoCircles = Exclude // "triangle" | "rectangle" The distributive property is key here. Exclude 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 7. Extract — 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 extends U ? T : never 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 // { type: "click"; x: number; y: number } type InputEvents = Extract // { type: "keypress"; key: string } | { type: "focus" } 8. Omit — Remove Specific Keys What it does: The inverse of Pick — returns a type with specific keys removed. How it works: type Omit = Pick > This is the most elegant composition of utility types. It: - Gets all keys of T withkeyof T - Removes K from that union withExclude - 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 // { id: string; title: string; completed: boolean } Note: In TypeScript 5.x+, Omit was updated soK no longer needs to extendkeyof T — you can pass keys that don't exist onT 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 — Get Function Parameter Types type Parameters any> = T extends (...args: infer P) => any ? P : never function createUser(name: string, age: number, email: string) { return { name, age, email } } type CreateUserArgs = Parameters // [name: string, age: number, email: string] ReturnType — Get Function Return Type type ReturnType any> = T extends (...args: any) => infer R ? R : any type CreateUserReturn = ReturnType // { name: string; age: number; email: string } ConstructorParameters and InstanceType These work on constructor signatures: class Database { constructor(public host: string, public port: number) {} } type DBParams = ConstructorParameters // [host: string, port: number] type DBInstance = InstanceType // Database NonNullable — Remove Null and Undefined type NonNullable = T extends null | undefined ? never : T type Maybe = string | null | undefined type Definitely = NonNullable // string 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 = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] } interface NestedConfig { server: { host: string; port: number } database: { url: string; poolSize: number } } function patchConfig(update: DeepPartial ) { // patchConfig({ server: { host: "new-host" } }) // ✅ Works deeply } Pattern 2: Pick by Value Type Sometimes you want to pick keys based on what value type they hold, not by name: type PickByValue = { [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 } type DateFields = PickByValue // { createdAt: Date; updatedAt: Date } 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 makes everything required. What if you only need a subset? type WithRequired = Omit & Required > interface Draft { title?: string body?: string tags?: string[] } type DraftWithTitle = WithRequired // title is required; body and tags stay optional Pattern 4: Substitute Property Types Need to change the type of one property without touching the rest? type Override >> = { [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 // { name: string; enabled: boolean; version: string } 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 // { data: string } | { message: string } // Both branches survive — only the status key is removed from each 🚨 Readonly is shallow interface User { name: string address: { city: string; zip: string } } const user: Readonly = { name: "Alice", address: { city: "NYC", zip: "10001" } } // user.name = "Bob" // ❌ Error // user.address.cit

Comments

No comments yet. Start the discussion.