noteUpdated March 2026: This article was originally published in December 2021 and has been updated and expanded with additional patterns and modern TypeScript workflows. Also, new banner image courtesy of Google Gemini.
You’re deep in a TypeScript project when you hit the wall: an API response with 50 properties, but your form only needs to update a dozen.
Do you manually copy those fields into a new type?
Paste the same shape three different ways?
Start reaching for any?
We’ve all been there. But TypeScript’s utility types exist specifically to solve this. They let you transform, compose, and manipulate types programmatically. Once you understand them, your type definitions become flexible, reusable, and maintainable.
Why Utility Types Actually Matter
Let me put this concretely. You’re building an API layer. Without utilities, you’re stuck:
- Option A - the “copy-paste trap”: Duplicate properties across
UserCreateDTO,UserUpdateDTO,UserResponseDTO… Add a new field? Update it in three places. Forget one? You’ll find out in production. - Option B - the “escape hatch”: Just use
any. It’s faster today. Runtime bugs are free tomorrow.
With utility types, you write the source type once and derive everything from it.
Add a field? It propagates automatically.
Rename something? TypeScript catches all the places it matters.
Your codebase scales because the types scale with it.
Part 1: Generics - The Foundation
Generics are the building blocks of everything we’ll discuss. They allow you to write types that can work with any type while maintaining type safety.
Basic Generic Functions
// Without generics - loses type informationfunction identity(value: any): any { return value;}
const result = identity("hello"); // type is 'any', autocomplete won't work
// With generics - preserves type informationfunction identity<T>(value: T): T { return value;}
const result = identity("hello"); // type is 'string'Generic Constraints
Sometimes you need generics, but with boundaries. A perfect example from real-world code: you want a function that works with objects, but needs to access specific properties. Probably everyone has come across this need.
// Without constraints - too broadfunction getProperty<T>(obj: T, key: keyof T) { return obj[key];}
// With constraints - more specificinterface User { id: number; name: string; email: string;}
// Only works with types that have an 'id' propertyfunction getId<T extends { id: number }>(obj: T): number { return obj.id;}
getId({ id: 1, name: "Alice" }); // worksgetId({ name: "Bob" }); // error - no id propertyGeneric Default Values
A lesser-known feature that’s incredibly useful:
// Generic with a default typeinterface ApiResponse<T = any> { status: number; data: T;}
// Works without specifying Tconst defaultResponse: ApiResponse = { status: 200, data: {} };
// Or specify a specific typeconst userResponse: ApiResponse<User> = { status: 200, data: { id: 1, name: "Alice", email: "alice@example.com" }};Part 2: Conditional Types - Logic in Your Types
Conditional types bring if/else logic to the type system. They evaluate based on the relationship between types.
Basic Conditional Types
// Syntax: T extends U ? X : Ytype IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // truetype B = IsString<123>; // falseReal-World Example: API Response Handler
interface SuccessResponse<T> { status: "success"; data: T;}
interface ErrorResponse { status: "error"; error: string;}
// Conditional type that returns different shapes based on inputtype ApiHandler<T> = T extends { status: "success" } ? SuccessResponse<unknown> : ErrorResponse;
// Or a more practical example:type Flatten<T> = T extends Array<infer U> ? U : T;
type Str = Flatten<string[]>; // stringtype Num = Flatten<number>; // numberThis pattern, combining types with a discriminator property, is actually a discriminated union. Learn more about discriminated unions in Part 3 of this series, where we explore how TypeScript uses these patterns to narrow types in your conditionals.
Distributive Conditional Types
This is where things get interesting. When you apply a conditional type to a union, it applies to each member of the union:
type Flatten<T> = T extends Array<infer U> ? U : T;
type Str = Flatten<string[] | number>; // string | number// TypeScript evaluates this as:// (string[] extends Array<infer U> ? U : string[]) | (number extends Array<infer U> ? U : number)// = string | numberPart 3: Mapped Types - Transform Objects
tipAs far as I can remember, once I started using mapped types, I have built almost every API layer using derived types.
One source interface generates the request shape, validation rules, and form state all at once. It sounds overkill until you rename a field and watch the entire system update without a single manual typo.
Mapped types let you create new types by transforming properties of existing types. They’re incredibly powerful for DRY code.
Basic Mapped Types
interface User { id: number; name: string; email: string;}
// Create a type where all properties are optionaltype Partial<T> = { [K in keyof T]?: T[K];};
type PartialUser = Partial<User>;// Results in:// {// id?: number;// name?: string;// email?: string;// }Readonly Version
type Readonly<T> = { readonly [K in keyof T]: T[K];};
type ReadonlyUser = Readonly<User>;// Results in:// {// readonly id: number;// readonly name: string;// readonly email: string;// }
const user: ReadonlyUser = { id: 1, name: "Alice", email: "alice@ex.com" };user.name = "Bob"; // error - property is readonlyReal-World: API Request Types
interface ApiEndpoint { GET: { response: User; params: { id: string } }; POST: { response: User; body: { name: string; email: string } }; PUT: { response: User; body: Partial<User> }; DELETE: { response: boolean; params: { id: string } };}
// Extract all response types from the endpointstype ApiResponses = { [K in keyof ApiEndpoint]: ApiEndpoint[K]["response"];};
// Results in:// {// GET: User;// POST: User;// PUT: User;// DELETE: boolean;// }
// Extract bodies onlytype ApiBodies = { [K in keyof ApiEndpoint as ApiEndpoint[K] extends { body: any } ? K : never]: ApiEndpoint[K] extends { body: infer B } ? B : never;};Key Remapping with as
This TypeScript 4.1+ feature lets you rename keys while mapping:
interface User { id: number; name: string; email: string;}
// Convert all properties to getterstype Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];};
type UserGetters = Getters<User>;// Results in:// {// getId: () => number;// getName: () => string;// getEmail: () => string;// }Part 4: Template Literal Types
Template literal types (introduced in TS 4.1) let you manipulate strings at the type level.
Basic Template Literals
type EventName = `on${Capitalize<"click" | "change" | "focus">}`;// Results in: "onClick" | "onChange" | "onFocus"
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";type ApiRoute = `${HttpMethod} /api/users`;// Results in: "GET /api/users" | "POST /api/users" | "PUT /api/users" | "DELETE /api/users"Real-World: Type-Safe Event Handlers
type Events = "click" | "hover" | "focus" | "blur";
type EventHandlers<T extends Events> = { [K in T as `on${Capitalize<K>}`]: (event: Event) => void;};
type ButtonHandlers = EventHandlers<"click" | "hover">;// Results in:// {// onClick: (event: Event) => void;// onHover: (event: Event) => void;// }Combining It All: A Production Example
Let’s build a type-safe form validator that combines everything we’ve learned:
interface FormSchema { name: string; email: string; age: number; subscribe: boolean;}
// Generate required fieldstype Required<T> = { [K in keyof T]-?: T[K]; // minus ? removes optional};
// Generate validation rulestype ValidationRules<T> = { [K in keyof T]: (value: T[K]) => boolean | string;};
// Generate form field configtype FormFieldConfig<T> = { [K in keyof T]: { value: T[K]; error?: string; touched: boolean; };};
// Create a validatorconst validator: ValidationRules<FormSchema> = { name: (value) => value.length > 0 || "Name is required", email: (value) => value.includes("@") || "Invalid email", age: (value) => value >= 18 || "Must be 18+", subscribe: () => true,};
// Create form stateconst formState: FormFieldConfig<FormSchema> = { name: { value: "", touched: false }, email: { value: "", error: undefined, touched: false }, age: { value: 0, touched: false }, subscribe: { value: false, touched: false },};Performance Considerations
While utility types are powerful, they can impact TypeScript compilation time if overused. Here’s what to watch for:
- Deeply nested generics: Each level of nesting adds complexity
- Excessive mapped types: Limit transformations to what you actually need
- Large union types: Distributive conditional types on large unions can slow things down
// Avoid: Overly complextype Nightmare<T> = T extends Array<infer U> ? U extends Promise<infer P> ? P extends { data: infer D } ? D : never : never : never;
// Better: Break it downtype UnwrapArray<T> = T extends Array<infer U> ? U : never;type UnwrapPromise<T> = T extends Promise<infer P> ? P : never;type ExtractData<T> = T extends { data: infer D } ? D : never;Wrapping up
- Generics are your foundation. Use them to create flexible, type-safe code
- Conditional types let you build logic into your types
- Mapped types help you transform and reduce duplication
- Template literals make working with string patterns type-safe
- Combine them to build powerful, composable type systems
The power of these utilities isn’t in using them individually. It’s in composing them to build type systems that prevent bugs before they happen. Once you start thinking in terms of derived types instead of duplicated ones, you’ll never want to go back to copy-paste.
Try these patterns in your projects. Start small. Maybe just a Partial<T> here or a discriminated union there. Once you feel the reduction in type boilerplate and see how much easier refactoring becomes, you’ll find yourself reaching for these patterns everywhere.
In the next part of this series, we’ll explore how to leverage TypeScript’s strict mode to catch even more issues at compile time.
Next in the series: TypeScript Strict Mode: Best Practices for Production Code





