Skip to main content
Advertisement

7.5 Building Custom Utility Types

TypeScript's built-in utility types (Partial, Required, Omit, etc.) cover many cases, but real-world development often calls for more sophisticated type transformations. This chapter covers powerful custom utility types you can implement and use directly. Mastering these types gives you a taste of "type-level programming" — treating types like data.


DeepPartial — Making All Nested Properties Optional

The built-in Partial<T> only makes properties optional one level deep. To make all nested object properties optional as well, you need a recursive type.

// Limitation of built-in Partial
interface Config {
server: {
host: string;
port: number;
ssl: {
enabled: boolean;
certPath: string;
};
};
database: {
host: string;
port: number;
};
}

type ShallowPartial = Partial<Config>;
// server?: { host: string; port: number; ssl: {...} } ← server is optional, but its internals are not

// DeepPartial implementation
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;

type DeepConfig = DeepPartial<Config>;
// server?: { host?: string; port?: number; ssl?: { enabled?: boolean; certPath?: string } }

// Practical example: merging configuration
function mergeConfig(base: Config, override: DeepPartial<Config>): Config {
return deepMerge(base, override) as Config;
}

function deepMerge<T extends object>(target: T, source: DeepPartial<T>): T {
const result = { ...target };
for (const key in source) {
const sourceVal = source[key as keyof typeof source];
const targetVal = target[key as keyof T];
if (sourceVal && typeof sourceVal === "object" && !Array.isArray(sourceVal)) {
(result as any)[key] = deepMerge(targetVal as any, sourceVal as any);
} else if (sourceVal !== undefined) {
(result as any)[key] = sourceVal;
}
}
return result;
}

// Improved version that does not recurse into arrays or functions
type DeepPartialStrict<T> =
T extends (infer U)[]
? DeepPartialStrict<U>[]
: T extends Function
? T
: T extends object
? { [P in keyof T]?: DeepPartialStrict<T[P]> }
: T;

DeepReadonly — Making All Nested Properties Readonly

// Limitation of built-in Readonly: only one level deep
type ShallowReadonly = Readonly<Config>;
// server: { readonly ... } but server.ssl.enabled can still be mutated

// DeepReadonly implementation
type DeepReadonly<T> =
T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends Map<infer K, infer V>
? ReadonlyMap<K, DeepReadonly<V>>
: T extends Set<infer V>
? ReadonlySet<DeepReadonly<V>>
: T extends Function
? T
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;

type ImmutableConfig = DeepReadonly<Config>;
// All nested properties are readonly

// Practical example: enforcing immutability of application state
interface AppState {
user: {
id: string;
profile: {
name: string;
avatar: string;
preferences: {
theme: "light" | "dark";
language: string;
};
};
};
cart: {
items: Array<{ productId: string; quantity: number; price: number }>;
total: number;
};
}

type ImmutableState = DeepReadonly<AppState>;

// Compile error — immutable state is protected
// function mutateState(state: ImmutableState): void {
// state.user.profile.name = "hacked"; // Error!
// state.cart.items.push({ ... }); // Error!
// }

// Correct approach: return a new object
function updateUserName(state: ImmutableState, name: string): ImmutableState {
return {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
name,
},
},
};
}

OmitByValue — Removing Properties by Value Type

Omit<T, K> removes by key name, while OmitByValue<T, V> removes all properties whose value type is V.

// OmitByValue implementation
type OmitByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? never : K]: T[K];
};

// PickByValue: the opposite — select only properties whose value type is V
type PickByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};

interface MixedInterface {
id: number;
name: string;
email: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
age: number | undefined;
}

// Remove boolean properties
type NoBoolean = OmitByValue<MixedInterface, boolean>;
// { id, name, email, createdAt, updatedAt, deletedAt, age }

// Select only string properties
type OnlyStrings = PickByValue<MixedInterface, string>;
// { name: string; email: string }

// Remove properties that allow null/undefined
type NoNullable = OmitByValue<MixedInterface, null | undefined>;
// { id, name, email, isActive, createdAt, updatedAt }

// Select only Date properties
type OnlyDates = PickByValue<MixedInterface, Date>;
// { createdAt: Date; updatedAt: Date }

// Practical example: keeping only JSON-serializable properties
type JsonSerializable = string | number | boolean | null | JsonSerializable[] | {
[key: string]: JsonSerializable;
};

// Remove function properties (preparing for serialization)
type SerializableProps<T> = OmitByValue<T, Function>;

class UserViewModel {
id: string = "";
name: string = "";
email: string = "";
formatName(): string { return this.name; }
validate(): boolean { return true; }
}

type SerializableUser = SerializableProps<UserViewModel>;
// { id: string; name: string; email: string }

RequireAtLeastOne — At Least One Property Required

Used when at least one of several optional fields must be present, such as search conditions or filters.

// RequireAtLeastOne implementation
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];

// Search parameters: at least one of id, email, or username is required
interface SearchParams {
id?: string;
email?: string;
username?: string;
includeDeleted?: boolean;
}

type ValidSearchParams = RequireAtLeastOne<SearchParams, "id" | "email" | "username">;

// Compile error — all three fields are missing
// const bad: ValidSearchParams = { includeDeleted: true }; // Error!

// OK — at least one is present
const byId: ValidSearchParams = { id: "user-123" };
const byEmail: ValidSearchParams = { email: "alice@example.com" };
const byMultiple: ValidSearchParams = { id: "u1", email: "alice@example.com" };

// Practical example: notification dispatch (at least one of email, SMS, or push required)
interface NotificationOptions {
title: string;
body: string;
email?: string;
smsPhone?: string;
pushToken?: string;
}

type SendNotification = RequireAtLeastOne<
NotificationOptions,
"email" | "smsPhone" | "pushToken"
>;

async function sendNotification(options: SendNotification): Promise<void> {
if (options.email) {
console.log(`Email to ${options.email}: ${options.title}`);
}
if (options.smsPhone) {
console.log(`SMS to ${options.smsPhone}: ${options.body}`);
}
if (options.pushToken) {
console.log(`Push to ${options.pushToken}: ${options.title}`);
}
}

RequireExactlyOne — Exactly One Property Required

Used when exactly one of several options must be chosen, such as a payment method.

// RequireExactlyOne implementation
type RequireExactlyOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]: Required<Pick<T, K>> & { [J in Exclude<Keys, K>]?: never };
}[Keys];

// Payment method: exactly one of card, bankTransfer, or crypto
interface PaymentBase {
amount: number;
currency: string;
card?: { cardNumber: string; expiry: string; cvv: string };
bankTransfer?: { accountNumber: string; routingNumber: string };
crypto?: { walletAddress: string; coin: "BTC" | "ETH" | "USDT" };
}

type Payment = RequireExactlyOne<PaymentBase, "card" | "bankTransfer" | "crypto">;

// OK — only card is present
const cardPayment: Payment = {
amount: 50000,
currency: "USD",
card: { cardNumber: "1234-5678-9012-3456", expiry: "12/26", cvv: "123" },
};

// Compile error — two methods specified simultaneously
// const doublePayment: Payment = {
// amount: 50000,
// currency: "USD",
// card: { ... },
// bankTransfer: { ... }, // Error! bankTransfer is never when card is present
// };

// Practical example: choosing an authentication method
interface AuthBase {
userId: string;
password?: string;
otp?: string;
biometric?: { type: "fingerprint" | "face"; data: string };
}

type AuthMethod = RequireExactlyOne<AuthBase, "password" | "otp" | "biometric">;

XOR — Exclusive OR Type

Implements exclusive OR, where exactly one of two types is possible.

// XOR implementation
type Without<T, U> = {
[P in Exclude<keyof T, keyof U>]?: never;
};

type XOR<T, U> = (T | U) extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;

// Only a rectangle or a circle, not both
interface Rectangle {
width: number;
height: number;
}

interface Circle {
radius: number;
}

type Shape = XOR<Rectangle, Circle>;

const rect: Shape = { width: 10, height: 20 }; // OK
const circ: Shape = { radius: 5 }; // OK
// const both: Shape = { width: 10, height: 20, radius: 5 }; // Error!

// Practical example: token auth vs session auth
interface TokenAuth {
token: string;
expiresAt: Date;
}

interface SessionAuth {
sessionId: string;
userId: string;
}

type AuthCredentials = XOR<TokenAuth, SessionAuth>;

function authenticate(credentials: AuthCredentials): Promise<boolean> {
if ("token" in credentials) {
// token authentication
return Promise.resolve(true);
} else {
// session authentication
return Promise.resolve(true);
}
}

// Practical example 2: Controlled vs Uncontrolled component (React pattern)
interface ControlledInput {
value: string;
onChange: (value: string) => void;
}

interface UncontrolledInput {
defaultValue?: string;
onBlur?: (value: string) => void;
}

type InputProps = XOR<ControlledInput, UncontrolledInput> & {
placeholder?: string;
disabled?: boolean;
};

// If value is present, onChange is also required (controlled)
// const bad: InputProps = { value: "hello" }; // Error: onChange is missing
const controlled: InputProps = { value: "hello", onChange: (v) => console.log(v) };
const uncontrolled: InputProps = { defaultValue: "world" };

Brand Types — Type-Safe IDs

In JavaScript/TypeScript, string is all the same type, but UserId and ProductId are semantically completely different. Brand types let you create types that are structurally identical but distinct in the type system.

// Brand type implementation
declare const __brand: unique symbol;

type Brand<T, BrandName extends string> = T & {
readonly [__brand]: BrandName;
};

// Domain ID types
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
type OrderId = Brand<string, "OrderId">;
type CategoryId = Brand<string, "CategoryId">;

// ID creation functions (the only way to create branded values)
function createUserId(id: string): UserId {
return id as UserId;
}

function createProductId(id: string): ProductId {
return id as ProductId;
}

function createOrderId(id: string): OrderId {
return id as OrderId;
}

// Usage
const userId = createUserId("user-123");
const productId = createProductId("prod-456");

// Function signatures guarantee type safety
function getUserById(id: UserId): Promise<{ id: UserId; name: string }> {
return Promise.resolve({ id, name: "Alice" });
}

function getProductById(id: ProductId): Promise<{ id: ProductId; title: string }> {
return Promise.resolve({ id, name: "Widget" } as any);
}

// Type error: UserId and ProductId are not interchangeable
// getUserById(productId); // Error! ProductId is not UserId
getUserById(userId); // OK

// Number-based brands
type Pixels = Brand<number, "Pixels">;
type Percentage = Brand<number, "Percentage">;
type Milliseconds = Brand<number, "Milliseconds">;

function px(value: number): Pixels { return value as Pixels; }
function pct(value: number): Percentage { return value as Percentage; }
function ms(value: number): Milliseconds { return value as Milliseconds; }

function setWidth(width: Pixels): void {
document.body.style.width = `${width}px`;
}

function setTimeout_safe(callback: () => void, delay: Milliseconds): void {
setTimeout(callback, delay);
}

setWidth(px(200)); // OK
// setWidth(pct(50)); // Error! Percentage is not Pixels
setTimeout_safe(() => {}, ms(1000)); // OK

Opaque Type Pattern

An extension of brand types that creates fully opaque types to hide internal implementation details.

// Opaque type: fully controlling creation and access
const OpaqueTag = Symbol("OpaqueTag");

type Opaque<T, Tag extends string> = T & {
readonly [OpaqueTag]: Tag;
};

// Password: the type guarantees the value is hashed
type HashedPassword = Opaque<string, "HashedPassword">;
type PlainPassword = Opaque<string, "PlainPassword">;

async function hashPassword(plain: PlainPassword): Promise<HashedPassword> {
// bcrypt hashing (real implementation)
const hashed = `hashed_${plain}`; // example
return hashed as HashedPassword;
}

async function verifyPassword(
plain: PlainPassword,
hashed: HashedPassword
): Promise<boolean> {
return (await hashPassword(plain)) === hashed;
}

function createPlainPassword(raw: string): PlainPassword {
return raw as PlainPassword;
}

// Usage flow
const userInput = createPlainPassword("mySecret123");
// const dbHash = "hashed_value_from_db" as HashedPassword;

// Preventing incorrect usage
// verifyPassword("plain_string", "hashed_string"); // Error!
// verifyPassword(userInput, userInput); // Error! second must be HashedPassword

// Validated email type
type ValidatedEmail = Opaque<string, "ValidatedEmail">;

function validateEmail(email: string): ValidatedEmail | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email) ? (email as ValidatedEmail) : null;
}

async function sendEmail(to: ValidatedEmail, subject: string, body: string): Promise<void> {
console.log(`Sending email to ${to}: ${subject}`);
}

// Cannot send email without validation
// sendEmail("invalid@", "Hi", "Body"); // Error!

const validEmail = validateEmail("alice@example.com");
if (validEmail) {
sendEmail(validEmail, "Welcome!", "Welcome to our service."); // OK
}

Practical Example 1: Form Validation Utility Types

// Form field validity state types
type FieldStatus = "untouched" | "valid" | "invalid";

interface FieldState<T> {
value: T;
status: FieldStatus;
error?: string;
}

// Automatically generate a form state type from the form type
type FormState<T extends Record<string, unknown>> = {
[K in keyof T]: FieldState<T[K]>;
} & {
isValid: boolean;
isSubmitting: boolean;
isDirty: boolean;
};

// Validation rule type
type ValidationRule<T> = (value: T) => string | null;

type ValidationSchema<T extends Record<string, unknown>> = {
[K in keyof T]?: ValidationRule<T[K]>[];
};

// Form hook return type
interface UseFormReturn<T extends Record<string, unknown>> {
state: FormState<T>;
setValue: <K extends keyof T>(field: K, value: T[K]) => void;
validate: <K extends keyof T>(field: K) => boolean;
validateAll: () => boolean;
reset: () => void;
handleSubmit: (handler: (data: T) => Promise<void>) => (e: Event) => void;
}

// Actual form data type
interface RegistrationForm {
username: string;
email: string;
password: string;
confirmPassword: string;
age: number;
agreeToTerms: boolean;
}

// Auto-generated form state type
type RegistrationFormState = FormState<RegistrationForm>;
/*
{
username: FieldState<string>;
email: FieldState<string>;
password: FieldState<string>;
confirmPassword: FieldState<string>;
age: FieldState<number>;
agreeToTerms: FieldState<boolean>;
isValid: boolean;
isSubmitting: boolean;
isDirty: boolean;
}
*/

// Validation schema type safety
const registrationSchema: ValidationSchema<RegistrationForm> = {
username: [
(v) => v.length >= 3 ? null : "Must be at least 3 characters",
(v) => /^[a-z0-9_]+$/.test(v) ? null : "Only lowercase letters, numbers, and _ are allowed",
],
email: [
(v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "Invalid email format",
],
password: [
(v) => v.length >= 8 ? null : "Must be at least 8 characters",
(v) => /[A-Z]/.test(v) ? null : "Must contain at least one uppercase letter",
],
age: [
(v) => v >= 14 ? null : "Must be 14 years or older to register",
],
};

// Error extraction utility
type FormErrors<T extends Record<string, unknown>> = {
[K in keyof T]?: string;
};

function extractErrors<T extends Record<string, unknown>>(
state: FormState<T>
): FormErrors<T> {
return Object.fromEntries(
Object.entries(state)
.filter(([key]) => key !== "isValid" && key !== "isSubmitting" && key !== "isDirty")
.filter(([, fieldState]) => (fieldState as FieldState<unknown>).error)
.map(([key, fieldState]) => [key, (fieldState as FieldState<unknown>).error])
) as FormErrors<T>;
}

Practical Example 2: Domain Model ID Branding

// Building a full domain ID system
declare const _brand: unique symbol;

type BrandedId<T extends string> = string & { readonly [_brand]: T };

// Domain-specific ID types
type UserId = BrandedId<"User">;
type PostId = BrandedId<"Post">;
type CommentId = BrandedId<"Comment">;
type TagId = BrandedId<"Tag">;

// ID factory (includes UUID generation)
function generateId<T extends string>(prefix: string): BrandedId<T> {
const uuid = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
return `${prefix}_${uuid}` as BrandedId<T>;
}

const newUserId = () => generateId<"User">("usr");
const newPostId = () => generateId<"Post">("pst");
const newCommentId = () => generateId<"Comment">("cmt");

// Domain entities (enforcing ID types)
interface User {
id: UserId;
name: string;
email: string;
postIds: PostId[];
}

interface Post {
id: PostId;
authorId: UserId;
title: string;
content: string;
commentIds: CommentId[];
tagIds: TagId[];
}

interface Comment {
id: CommentId;
postId: PostId;
authorId: UserId;
content: string;
}

// Repository: guarantees ID type safety
class UserRepository {
private users = new Map<UserId, User>();

save(user: User): void {
this.users.set(user.id, user);
}

findById(id: UserId): User | undefined {
return this.users.get(id);
}

// Error: cannot look up a User with a PostId
// findByPostId(id: PostId): User | undefined {
// return this.users.get(id); // Error! PostId is not UserId
// }
}

class PostRepository {
private posts = new Map<PostId, Post>();

findByAuthor(authorId: UserId): Post[] {
return [...this.posts.values()].filter(p => p.authorId === authorId);
}

// UserId and PostId cannot be confused
findById(id: PostId): Post | undefined {
return this.posts.get(id);
}
}

// Usage example: guaranteeing relationship safety
async function getUserWithPosts(
userId: UserId,
userRepo: UserRepository,
postRepo: PostRepository
): Promise<{ user: User; posts: Post[] } | null> {
const user = userRepo.findById(userId);
if (!user) return null;

const posts = postRepo.findByAuthor(userId);
return { user, posts };
}

// Example of preventing mistakes
const uid = newUserId();
const pid = newPostId();

// getUserWithPosts(pid, userRepo, postRepo); // Error! pid is a PostId
getUserWithPosts(uid, new UserRepository(), new PostRepository()); // OK

Pro Tips

Packaging Custom Utility Types as a Library

// utilities/types.ts — project utility type collection
export type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;

export type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;

export type OmitByValue<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K];
};

export type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};

export 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];

declare const _brand: unique symbol;
export type Brand<T, B extends string> = T & { readonly [_brand]: B };

export type Prettify<T> = { [K in keyof T]: T[K] } & {};

export type Mutable<T> = { -readonly [P in keyof T]: T[P] };

export type DeepMutable<T> = T extends object
? { -readonly [P in keyof T]: DeepMutable<T[P]> }
: T;

// Function type utilities
export type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
export type AsyncReturnType<T extends (...args: any) => Promise<any>> =
Awaited<ReturnType<T>>;

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>>;

// Tuple utilities
export type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
export type Tail<T extends any[]> = T extends [any, ...infer T] ? T : never;
export type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;

// Object utilities
export type ValueOf<T> = T[keyof T];
export type KeysOfType<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T];

Introducing type-fest

type-fest is a trusted TypeScript utility type library.

// Installation: npm install type-fest

import type {
Simplify, // Spreads intersection types into a simple object
Merge, // Merges two object types (latter takes precedence)
PartialDeep, // DeepPartial
ReadonlyDeep, // DeepReadonly
LiteralUnion, // Literal union + base type
SetOptional, // Makes specific keys optional
SetRequired, // Makes specific keys required
Opaque, // Opaque type
Promisable, // T | Promise<T>
JsonValue, // JSON-serializable type
CamelCase, // snake_case → camelCase
SnakeCase, // camelCase → snake_case
Get, // Deep access using dot notation
} from "type-fest";

// Simplify example
type Complex = { a: string } & { b: number } & { c: boolean };
type Simple = Simplify<Complex>; // { a: string; b: number; c: boolean }

// LiteralUnion: autocomplete + arbitrary strings both allowed
type FontSize = LiteralUnion<"sm" | "md" | "lg" | "xl", string>;
const size: FontSize = "sm"; // autocomplete works
const custom: FontSize = "2rem"; // arbitrary value also OK

// SetOptional: makes specific keys optional
interface UserRequired {
id: string;
name: string;
email: string;
age: number;
}
type UserUpdate = SetOptional<UserRequired, "name" | "email" | "age">;
// { id: string; name?: string; email?: string; age?: number }

// Get: deep type access using dot notation
type Config = { server: { host: string; port: number } };
type Host = Get<Config, "server.host">; // string

Summary

Utility TypeImplementation CorePrimary Use
DeepPartial<T>Recursion + ? modifierConfig overrides, partial updates
DeepReadonly<T>Recursion + readonly modifierImmutable state, Redux state
OmitByValue<T, V>as never key filteringSerialization, removing functions
PickByValue<T, V>as K key filteringType-based selection
RequireAtLeastOne<T>Distributive conditional types + unionSearch params, filters
RequireExactlyOne<T>never key exclusionPayment methods, auth methods
XOR<T, U>Mutual exclusion + unionControlled/uncontrolled components
Brand<T, B>unique symbol intersectionDomain IDs, unit types
Opaque<T, Tag>Symbol tag intersectionHashed passwords, validated emails
PartialBy<T, K>Omit + Partial Pick combinationPartially optional fields

In the next chapter, we cover TypeScript's module system and type declarations. You will learn how to write .d.ts files, use module augmentation, and work with ambient declarations to use external libraries in a type-safe way.

Advertisement