Advanced Utility Type Patterns
Advanced Utility Type Patterns
TypeScript's utility types are powerful building blocks, but the real magic happens when you combine and extend them to create sophisticated custom type transformations. In this lesson, we'll explore advanced utility type patterns including DeepPartial, DeepReadonly, PathKeys for nested object access, and techniques for building reusable type utilities that solve complex typing challenges.
Why Build Custom Utility Types?
Custom utility types enable you to:
- Reduce Repetition: Define complex type transformations once and reuse them throughout your codebase
- Enforce Consistency: Standardize how types are transformed across your application
- Improve Maintainability: Centralize type logic in reusable utilities rather than duplicating it
- Express Intent: Create self-documenting type names that clearly communicate purpose
- Handle Edge Cases: Build utilities that correctly handle complex nested structures
DeepPartial - Recursive Optional Properties
The built-in Partial<T> only makes top-level properties optional. DeepPartial recursively makes all nested properties optional:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[P]>
: T[P];
};
// Example usage
interface User {
id: number;
profile: {
firstName: string;
lastName: string;
address: {
street: string;
city: string;
country: string;
};
};
preferences: {
theme: 'light' | 'dark';
notifications: {
email: boolean;
push: boolean;
};
};
}
// All properties at all levels become optional
type PartialUser = DeepPartial<User>;
const updateUser: PartialUser = {
profile: {
address: {
city: 'New York', // Can update just one nested property
},
},
};
DeepPartial is especially useful for update operations where you want to allow partial updates to deeply nested objects without requiring all properties at every level.
DeepReadonly - Recursive Immutability
Make all properties at all nesting levels readonly to enforce complete immutability:
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? T[P] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[P]>
: T[P];
};
// Example usage
interface Config {
api: {
baseURL: string;
timeout: number;
headers: {
authorization: string;
contentType: string;
};
};
features: {
darkMode: boolean;
analytics: boolean;
};
}
type ImmutableConfig = DeepReadonly<Config>;
const config: ImmutableConfig = {
api: {
baseURL: 'https://api.example.com',
timeout: 5000,
headers: {
authorization: 'Bearer token',
contentType: 'application/json',
},
},
features: {
darkMode: true,
analytics: false,
},
};
// TypeScript error: Cannot assign to 'baseURL' because it is a read-only property
// config.api.baseURL = 'https://new-api.com';
// TypeScript error: Cannot assign to 'darkMode' because it is a read-only property
// config.features.darkMode = false;
DeepRequired - Recursive Required Properties
Make all optional properties required at all nesting levels:
type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object
? T[P] extends Array<infer U>
? Array<DeepRequired<U>>
: DeepRequired<T[P]>
: T[P];
};
// Example with optional nested properties
interface PartialForm {
username?: string;
profile?: {
bio?: string;
social?: {
twitter?: string;
github?: string;
};
};
}
type CompleteForm = DeepRequired<PartialForm>;
// TypeScript error: All properties must be provided
const form: CompleteForm = {
username: 'john_doe',
profile: {
bio: 'Developer',
social: {
twitter: '@johndoe',
github: 'johndoe',
},
},
};
-? modifier removes the optional flag from properties. This is the opposite of ? which adds the optional flag.
PathKeys - Type-Safe Object Path Access
Generate string literal types representing all valid paths through a nested object:
type PathKeys<T> = T extends object
? {
[K in keyof T]: K extends string
? T[K] extends object
? `${K}` | `${K}.${PathKeys<T[K]>}`
: `${K}`
: never;
}[keyof T]
: never;
// Example usage
interface Settings {
user: {
name: string;
email: string;
preferences: {
theme: string;
language: string;
};
};
app: {
version: string;
features: {
darkMode: boolean;
notifications: boolean;
};
};
}
type SettingPath = PathKeys<Settings>;
// Result type:
// "user" | "user.name" | "user.email" | "user.preferences" |
// "user.preferences.theme" | "user.preferences.language" |
// "app" | "app.version" | "app.features" |
// "app.features.darkMode" | "app.features.notifications"
// Type-safe getter function
function getNestedValue<T, P extends PathKeys<T>>(
obj: T,
path: P
): any {
return path.split('.').reduce((acc: any, key) => acc?.[key], obj);
}
const settings: Settings = {
user: {
name: 'John',
email: 'john@example.com',
preferences: {
theme: 'dark',
language: 'en',
},
},
app: {
version: '1.0.0',
features: {
darkMode: true,
notifications: false,
},
},
};
const theme = getNestedValue(settings, 'user.preferences.theme'); // Valid
// const invalid = getNestedValue(settings, 'user.invalid.path'); // TypeScript error
Mutable - Remove Readonly Modifiers
Create a writable version of a readonly type:
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// Deep version
type DeepMutable<T> = {
-readonly [P in keyof T]: T[P] extends object
? T[P] extends Array<infer U>
? Array<DeepMutable<U>>
: DeepMutable<T[P]>
: T[P];
};
// Example
interface ReadonlyUser {
readonly id: number;
readonly name: string;
readonly profile: {
readonly bio: string;
};
}
type WritableUser = DeepMutable<ReadonlyUser>;
const user: WritableUser = {
id: 1,
name: 'John',
profile: { bio: 'Developer' },
};
user.name = 'Jane'; // Allowed
user.profile.bio = 'Designer'; // Allowed
Mutable sparingly. Readonly types exist for a reason - removing readonly modifiers can compromise immutability guarantees that other parts of your codebase rely on.
PickByType - Select Properties by Value Type
Create a type containing only properties whose values match a specific type:
type PickByType<T, U> = {
[P in keyof T as T[P] extends U ? P : never]: T[P];
};
// Opposite: OmitByType
type OmitByType<T, U> = {
[P in keyof T as T[P] extends U ? never : P]: T[P];
};
// Example usage
interface Product {
id: number;
name: string;
price: number;
description: string;
inStock: boolean;
rating: number;
category: string;
}
// Get only string properties
type StringProps = PickByType<Product, string>;
// Result: { name: string; description: string; category: string }
// Get only number properties
type NumberProps = PickByType<Product, number>;
// Result: { id: number; price: number; rating: number }
// Get only non-string properties
type NonStringProps = OmitByType<Product, string>;
// Result: { id: number; price: number; inStock: boolean; rating: number }
RequireAtLeastOne - Require One of Many Properties
Ensure at least one property from a set is provided:
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
T,
Exclude<keyof T, Keys>
> &
{
[K in Keys]-?: Required<Pick<T, K>> &
Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
// Example usage
interface ContactInfo {
name: string;
email?: string;
phone?: string;
address?: string;
}
type ContactRequired = RequireAtLeastOne<
ContactInfo,
'email' | 'phone' | 'address'
>;
// Valid: has email
const contact1: ContactRequired = {
name: 'John',
email: 'john@example.com',
};
// Valid: has phone
const contact2: ContactRequired = {
name: 'Jane',
phone: '555-0123',
};
// Valid: has multiple
const contact3: ContactRequired = {
name: 'Bob',
email: 'bob@example.com',
phone: '555-0456',
};
// TypeScript error: must have at least one of email, phone, or address
// const invalid: ContactRequired = {
// name: 'Invalid',
// };
RequireExactlyOne - Require Exactly One Property
Ensure exactly one property from a set is provided (mutually exclusive properties):
type RequireExactlyOne<T, Keys extends keyof T = keyof T> = Pick<
T,
Exclude<keyof T, Keys>
> &
{
[K in Keys]-?: Required<Pick<T, K>> &
Partial<Record<Exclude<Keys, K>, undefined>>;
}[Keys];
// Example usage
interface SearchParams {
query?: string;
userId?: number;
email?: string;
}
type ExactSearch = RequireExactlyOne<
SearchParams,
'query' | 'userId' | 'email'
>;
// Valid: exactly one property
const search1: ExactSearch = { query: 'typescript' };
const search2: ExactSearch = { userId: 123 };
const search3: ExactSearch = { email: 'user@example.com' };
// TypeScript error: cannot provide multiple properties
// const invalid1: ExactSearch = { query: 'ts', userId: 1 };
// TypeScript error: must provide exactly one property
// const invalid2: ExactSearch = {};
Prettify - Flatten Intersection Types for Better IDE Display
TypeScript intersection types can be hard to read in IDE tooltips. Prettify flattens them:
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Example
type Base = { id: number; name: string };
type Extended = Base & { email: string; age: number };
// Without Prettify, IDE shows: Base & { email: string; age: number }
type Ugly = Extended;
// With Prettify, IDE shows: { id: number; name: string; email: string; age: number }
type Pretty = Prettify<Extended>;
Prettify when creating complex utility types to make IDE hover tooltips more readable. It doesn't change the type behavior, only how it's displayed.
PartialBy - Make Specific Properties Optional
Make only specific properties optional while keeping others required:
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Example
interface User {
id: number;
email: string;
username: string;
firstName: string;
lastName: string;
}
// Make only email and username optional
type UserUpdate = PartialBy<User, 'email' | 'username'>;
const update: UserUpdate = {
id: 1,
firstName: 'John',
lastName: 'Doe',
// email and username are optional
};
Building a Comprehensive Utility Type Library
Combine utilities into a reusable library:
// types/utils.ts
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[P]>
: T[P];
};
export type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? T[P] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[P]>
: T[P];
};
export type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object
? T[P] extends Array<infer U>
? Array<DeepRequired<U>>
: DeepRequired<T[P]>
: T[P];
};
export type DeepMutable<T> = {
-readonly [P in keyof T]: T[P] extends object
? T[P] extends Array<infer U>
? Array<DeepMutable<U>>
: DeepMutable<T[P]>
: T[P];
};
export type PickByType<T, U> = {
[P in keyof T as T[P] extends U ? P : never]: T[P];
};
export type OmitByType<T, U> = {
[P in keyof T as T[P] extends U ? never : P]: T[P];
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export type PartialBy<T, K extends keyof T> = Omit<T, K> &
Partial<Pick<T, K>>;
export type RequiredBy<T, K extends keyof T> = Omit<T, K> &
Required<Pick<T, K>>;
// Usage across your app
import { DeepPartial, DeepReadonly, Prettify } from './types/utils';
- Implement a
DeepNonNullable<T>utility that removesnullandundefinedfrom all properties recursively - Create a
ValueOf<T>utility that extracts all possible value types from an object type - Build a
StrictOmit<T, K>that throws a compile error if K contains keys not in T (unlike built-in Omit) - Implement
PathValue<T, P>that returns the type at a specific path string (e.g., "user.profile.name") - Create a
PromisifyMethods<T>utility that wraps all method return types in Promises - Build a comprehensive utility type library with at least 10 custom utilities and use them in a real-world data model
Summary
In this lesson, you explored advanced utility type patterns that extend TypeScript's type system to handle complex real-world scenarios. You learned to build DeepPartial, DeepReadonly, and other recursive utilities, create type-safe object path accessors with PathKeys, select properties by type, enforce "at least one" and "exactly one" property constraints, and flatten intersection types for better readability. These patterns empower you to create sophisticated, reusable type utilities that reduce boilerplate, enforce complex constraints, and make your codebase more maintainable and type-safe.