Mastering TypeScript Utility Types: Advanced Patterns in 2026
Build Type-Safe Applications with Generics, Conditional Types, and Mapped Types

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 information
function identity(value: any): any {
return value;
}
const result = identity("hello"); // type is 'any', autocomplete won't work
// With generics - preserves type information
function 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 broad
function getProperty<T>(obj: T, key: keyof T) {
return obj[key];
}
// With constraints - more specific
interface User {
id: number;
name: string;
email: string;
}
// Only works with types that have an 'id' property
function getId<T extends { id: number }>(obj: T): number {
return obj.id;
}
getId({ id: 1, name: "Alice" }); // works
getId({ name: "Bob" }); // error - no id propertyGeneric Default Values
A lesser-known feature that’s incredibly useful:
// Generic with a default type
interface ApiResponse<T = any> {
status: number;
data: T;
}
// Works without specifying T
const defaultResponse: ApiResponse = { status: 200, data: {} };
// Or specify a specific type
const 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 : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type 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 input
type 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[]>; // string
type 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 optional
type 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 endpoints
type ApiResponses = {
[K in keyof ApiEndpoint]: ApiEndpoint[K]["response"];
};
// Results in:
// {
// GET: User;
// POST: User;
// PUT: User;
// DELETE: boolean;
// }
// Extract bodies only
type 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 getters
type 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 fields
type Required<T> = {
[K in keyof T]-?: T[K]; // minus ? removes optional
};
// Generate validation rules
type ValidationRules<T> = {
[K in keyof T]: (value: T[K]) => boolean | string;
};
// Generate form field config
type FormFieldConfig<T> = {
[K in keyof T]: {
value: T[K];
error?: string;
touched: boolean;
};
};
// Create a validator
const 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 state
const 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 complex
type Nightmare<T> = T extends Array<infer U>
? U extends Promise<infer P>
? P extends { data: infer D }
? D
: never
: never
: never;
// Better: Break it down
type 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





