Building Custom Utility Types
TypeScript ships with built-in utility types like Partial, Required, Readonly, and Pick,
but real-world projects constantly need types that go deeper. In this guide you will build
each custom utility from scratch, learning the thought process behind every design decision.
DeepReadonly
The built-in Readonly<T> only freezes the top-level properties. Nested objects remain mutable.
DeepReadonly recursively walks every property and marks it readonly.
// Built-in Readonly only goes one level deep
type ShallowReadonly = Readonly<{
user: { name: string; age: number };
}>;
// user itself is readonly but user.name is still writable
const s: ShallowReadonly = { user: { name: 'Alice', age: 30 } };
// s.user = { name: 'Bob', age: 25 }; // Error
s.user.name = 'Bob'; // No error — shallow!
// ---------- DeepReadonly ----------
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Config = {
server: { host: string; port: number };
database: { url: string; poolSize: number };
};
const cfg: DeepReadonly<Config> = {
server: { host: 'localhost', port: 3000 },
database: { url: 'postgres://...', poolSize: 5 },
};
// cfg.server.host = 'prod'; // Error — deeply readonly
// cfg.database = { ... }; // ErrorT[K] extends object recurses for objects. Primitives fall through to T[K] unchanged.DeepPartial
Partial<T> makes top-level keys optional. DeepPartial cascades that optionality through
every nested object — extremely useful for patch and update payloads.
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
type User = {
id: number;
profile: {
name: string;
address: {
city: string;
zip: string;
};
};
};
type UserPatch = DeepPartial<User>;
// Equivalent to:
// {
// id?: number;
// profile?: {
// name?: string;
// address?: { city?: string; zip?: string };
// };
// }
function updateUser(id: number, patch: UserPatch) {
console.log('Patching user', id, 'with', patch);
}
updateUser(1, { profile: { address: { city: 'Berlin' } } }); // OKDeepPartial with a deep-merge utility to build a flexible settings system where callers only supply the keys they want to override.DeepRequired
The mirror image of DeepPartial. Useful when you have an optional config type and want to
assert that, after applying defaults, every field is present.
type DeepRequired<T> = {
[K in keyof T]-?: T[K] extends object ? DeepRequired<T[K]> : T[K];
};
// The -? modifier strips the optional marker from every mapped key.
type AppOptions = {
theme?: {
color?: string;
fontSize?: number;
};
debug?: boolean;
};
type ResolvedOptions = DeepRequired<AppOptions>;
// {
// theme: { color: string; fontSize: number };
// debug: boolean;
// }
function applyDefaults(opts: AppOptions): ResolvedOptions {
return {
theme: {
color: opts.theme?.color ?? 'blue',
fontSize: opts.theme?.fontSize ?? 14,
},
debug: opts.debug ?? false,
} as ResolvedOptions;
}ValueOf
ValueOf<T> extracts a union of all value types in an object — the value-side counterpart
to keyof.
type ValueOf<T> = T[keyof T];
const STATUS = {
PENDING: 'pending',
ACTIVE: 'active',
CLOSED: 'closed',
} as const;
type Status = ValueOf<typeof STATUS>;
// 'pending' | 'active' | 'closed'
function setStatus(s: Status) {
console.log('Status:', s);
}
setStatus('active'); // OK
// setStatus('unknown'); // Error
// Also works with numeric values
const HTTP = { OK: 200, NOT_FOUND: 404, ERROR: 500 } as const;
type HttpCode = ValueOf<typeof HTTP>; // 200 | 404 | 500keyof T gives the union of key types. Indexing T with that union gives the corresponding value types.Flatten / UnpackArray
These two utilities unwrap one level of nesting — arrays into their element type, and
Promise wrappers into their resolved type.
// Unwrap one level of array
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;
type A = UnpackArray<string[]>; // string
type B = UnpackArray<number[][]>; // number[] (one level only)
type C = UnpackArray<string>; // string (not an array, passthrough)
// Recursively flatten nested arrays
type Flatten<T> = T extends Array<infer Item> ? Flatten<Item> : T;
type D = Flatten<number[][][]>; // number
type E = Flatten<string[]>; // string
// Real-world example: flatten API response types
type ApiResponse<T> = { data: T; error: string | null };
type FlatData<R> = R extends ApiResponse<infer D> ? D : never;
type UserList = { id: number; name: string }[];
type UserResponse = ApiResponse<UserList>;
type Users = FlatData<UserResponse>; // { id: number; name: string }[]Nullable
A tiny but frequently useful alias that adds null (and optionally undefined) to any type.
// Adds null
type Nullable<T> = T | null;
// Adds null and undefined
type Maybe<T> = T | null | undefined;
type UserRecord = { id: number; name: string };
function findUser(id: number): Nullable<UserRecord> {
if (id < 0) return null;
return { id, name: 'Alice' };
}
const user: Nullable<UserRecord> = findUser(99);
if (user !== null) {
console.log(user.name); // TypeScript knows user is UserRecord here
}
// Maybe is handy for optional function arguments
function greet(name: Maybe<string>) {
if (name == null) { // catches both null and undefined
return 'Hello, stranger!';
}
return `Hello, ${name}!`;
}OmitNever
When you build conditional mapped types, some properties resolve to never — a dead end
that clutters the resulting type. OmitNever removes them using key remapping.
type OmitNever<T> = {
[K in keyof T as T[K] extends never ? never : K]: T[K];
};
// Example: extract only string-valued properties
type StringFields<T> = OmitNever<{
[K in keyof T]: T[K] extends string ? T[K] : never;
}>;
type Product = {
id: number;
name: string;
sku: string;
price: number;
category: string;
};
type ProductStrings = StringFields<Product>;
// { name: string; sku: string; category: string }
// id and price resolved to never and OmitNever dropped them
// Without OmitNever the type would be cluttered:
// { id: never; name: string; sku: string; price: never; category: string }as T[K] extends never ? never : K. Mapping a key to never removes it from the output object entirely.PickByValue
Where Pick selects properties by name, PickByValue selects them by value type.
type PickByValue<T, ValueType> = OmitNever<{
[K in keyof T]: T[K] extends ValueType ? T[K] : never;
}>;
// Counterpart: omit by value
type OmitByValue<T, ValueType> = OmitNever<{
[K in keyof T]: T[K] extends ValueType ? never : T[K];
}>;
type Form = {
username: string;
age: number;
email: string;
score: number;
active: boolean;
};
type StringFormFields = PickByValue<Form, string>;
// { username: string; email: string }
type NumericFormFields = PickByValue<Form, number>;
// { age: number; score: number }
type NonNumericFields = OmitByValue<Form, number>;
// { username: string; email: string; active: boolean }XOR — Exclusive Or
TypeScript unions allow both variants simultaneously: A | B accepts an object that
satisfies both A and B. XOR enforces that exactly one variant applies.
// Building block: make all keys of T optional and set to never
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
// XOR: T without U's extra keys, or U without T's extra keys
type XOR<T, U> = (T & Without<U, T>) | (U & Without<T, U>);
// Example: a button can be either a link button or an action button, not both
type LinkButton = { href: string; label: string };
type ActionButton = { onClick: () => void; label: string };
type Button = XOR<LinkButton, ActionButton>;
const link: Button = { href: '/home', label: 'Home' }; // OK
const action: Button = { onClick: () => {}, label: 'Save' }; // OK
// const both: Button = { // Error
// href: '/home',
// onClick: () => {},
// label: 'Both',
// };
// Real-world: API credential types
type ApiKeyAuth = { apiKey: string };
type BearerAuth = { token: string };
type Auth = XOR<ApiKeyAuth, BearerAuth>;type field when readability matters more than strict mutual exclusion.Awaited — Manual vs Built-in
TypeScript 4.5 shipped a built-in Awaited<T> type. Building it manually is an
excellent exercise in recursive conditional types and understanding how TypeScript
models thenables.
// Manual implementation (educational — use the built-in in production)
type MyAwaited<T> =
T extends null | undefined
? T // preserve null/undefined
: T extends object & { then(onfulfilled: infer F, ...args: any[]): any }
? F extends (value: infer V, ...args: any[]) => any
? MyAwaited<V> // recurse for nested promises
: never
: T; // not a thenable, return as-is
type P1 = MyAwaited<Promise<string>>; // string
type P2 = MyAwaited<Promise<Promise<number>>>; // number
type P3 = MyAwaited<string>; // string (passthrough)
type P4 = MyAwaited<null>; // null
// The built-in (TypeScript 4.5+)
type A1 = Awaited<Promise<string>>; // string
type A2 = Awaited<Promise<Promise<number>>>; // number
// Practical usage: extract the resolved type of any async function
async function fetchUser(): Promise<{ id: number; name: string }> {
return { id: 1, name: 'Alice' };
}
type FetchResult = Awaited<ReturnType<typeof fetchUser>>;
// { id: number; name: string }DeepReadonly<PickByValue<Config, string>> gives a deeply immutable view of just the string fields in a config object.Quick Reference
Utility | What it does | Typical use case |
|---|---|---|
DeepReadonly<T> | Recursively marks all fields readonly | Config objects, frozen state |
DeepPartial<T> | Recursively makes all fields optional | Patch/update payloads |
DeepRequired<T> | Recursively removes optional markers | Resolved config after defaults |
ValueOf<T> | Union of all value types | Enum-like const objects |
Flatten<T> | Recursively unwraps nested arrays | Normalising API responses |
Nullable<T> | T | null | Values that may be absent |
OmitNever<T> | Removes never-typed keys | Cleaning up mapped types |
PickByValue<T, V> | Keeps only keys whose value extends V | Form field filtering |
XOR<T, U> | Exactly one of T or U, not both | Mutually exclusive options |
Awaited<T> | Unwraps Promise (built-in TS 4.5+) | Async return type extraction |