TypeScript 38 ๐ท Type Composition Patterns
Every utility and operator in the previous chapters is a building block โ Partial, Pick, Omit, Record, Exclude, Extract, ReturnType, keyof, infer, template literals. Individually they solve small problems. Together they compose into patterns that express real domain shapes. This chapter is about that composition โ how to combine the pieces to model functions, state machines, APIs, forms, and configurations without duplicating a single property. The goal is a mental library of compositions you can reach for when a problem fits.
Key point: Type composition is about factoring. You define the smallest set of source types and derive everything else. The utilities and operators are the tools; the patterns are recipes. Learn a handful โ Partial<Omit<T, ...>>, Pick<T, K> & Partial<...>, discriminated unions, branded types โ and you can express almost any shape without repeating declarations.
Why composition matters
A type system gets unwieldy when everything is declared independently. Change one property in the source of truth and every related type drifts.
Without composition:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
interface PublicUser {
id: number;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
interface CreateUserInput {
name: string;
email: string;
password: string;
}
interface UpdateUserInput {
name?: string;
email?: string;
password?: string;
}
Four declarations, all duplicating properties. Add a role field, and you must update every interface.
With composition:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
type PublicUser = Omit<User, 'password'>;
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
One source of truth. Change User, and every derived type updates.
The principle: Define the source once; derive everything else. The utilities are the derivation tools. Composition is the practice of using them.
Why this matters at scale: In a codebase with dozens of entities, duplication compounds. Composition keeps the source small and the derivations automatic. That’s the difference between types that drift and types that stay in sync.
Why composition beats declaration: It’s DRY at the type level. Every derived type is a computation from the source. When the source changes, the compiler recomputes. You can’t have a stale derivation โ the compiler would error if the shape changed incompatibly.
Pattern 1 โ View projections
Given a large type, derive views for different audiences.
Public view โ hide sensitive fields:
type PublicUser = Omit<User, 'password' | 'internalNotes'>;
Summary view โ a few fields:
type UserSummary = Pick<User, 'id' | 'name' | 'avatar'>;
Detailed view โ most fields, minus internal:
type UserDetail = Omit<User, 'internalNotes' | 'auditLog'>;
Admin view โ everything:
type AdminUser = User;
The rule: Pick when you want a few fields; omit when you want most. The intent is clearer with the shorter list.
Composing views:
type UserCard = Pick<User, 'id' | 'name' | 'avatar'> & {
postCount: number;
};
Start with a Pick, add computed fields. The base view stays in sync with User.
Why views are the most common pattern: Almost every app has multiple representations of the same entity โ a list item, a detail page, a form, an API response. Deriving them keeps them consistent.
Why
PickandOmitcompose so well: They’re pure projections. Any subset is expressible. And they don’t introduce new fields โ they only select. Adding new fields requires an intersection with a new shape. That’s a clean separation: projections select, intersections extend.
Pattern 2 โ Input types
Input types for API calls are usually the entity minus server-generated fields, sometimes with optionality.
Create input โ no ID, no timestamps:
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
Update input โ partial, no immutable fields:
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt'>>;
Patch input โ only changed fields:
type PatchUserInput = Partial<Pick<User, 'name' | 'email'>>;
Filter input โ a subset of fields for filtering:
type UserFilter = Partial<Pick<User, 'role' | 'active' | 'email'>>;
Composing with required fields:
type UpsertInput = Pick<User, 'email'> & Partial<Omit<User, 'email' | 'id'>>;
The email is required (it identifies the user); everything else is optional.
Why input types are always derived: They’re the entity with adjustments. Deriving keeps them consistent with the entity’s shape. Server changes the entity, input types follow.
Why Partial<Omit<T, ...>> is idiomatic for updates: The omit removes immutable fields; the partial makes the rest optional. That’s exactly what an update is โ some fields, none required.
Why separate Create and Update types: Create needs the required fields the server expects. Update allows any subset. They’re different shapes derived from the same source. Trying to share one type would blur the distinction.
Pattern 3 โ Discriminated union results
A union of object types with a literal discriminant โ the pattern from Chapter 16.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Each branch has a literal discriminant (ok). Narrowing on it narrows the whole object.
Composing with other shapes:
interface Base {
requestId: string;
timestamp: Date;
}
type Success<T> = Base & { status: 'success'; data: T };
type Failure = Base & { status: 'failure'; error: string };
type Loading = Base & { status: 'loading' };
type RequestState<T> = Success<T> | Failure | Loading;
The Base intersection factors out the shared fields. Each variant adds its discriminant and payload.
Adding more variants:
type PartialSuccess<T> = Base & { status: 'partial'; data: T; warnings: string[] };
type RequestState<T> = Success<T> | Failure | Loading | PartialSuccess<T>;
Extending the union with a new variant is one line. Every consumer that narrows is checked.
Why this pattern is everywhere: Any “one of several outcomes” โ results, events, states, actions โ is a discriminated union. The Base & { ... } composition factors out shared fields.
Why the discriminant must be a literal: Narrowing needs distinct literal types. status: 'success' matches one branch; status: string matches all.
Why
Base & { ... }instead of extending: Intersections compose. Extending requires named interfaces. For inline variants,Base & { ... }is shorter and works with type aliases. The result is identical.
Pattern 4 โ Required and optional splits
Split a type into required and optional parts.
Required identifier + optional fields:
type UpdateInput = Pick<User, 'id'> & Partial<Omit<User, 'id'>>;
The id is required (identifies which user); everything else is optional. Common in PATCH APIs.
Required fields + optional extras:
type ConfigInput = Required<Pick<Config, 'host' | 'port'>> & Partial<Omit<Config, 'host' | 'port'>>;
The core fields must be present; the rest can be supplied.
All required except a subset:
type StrictUser = Omit<User, 'nickname'> & Required<Pick<User, 'nickname'>>;
nickname was optional, now required.
Composing with Partial:
type Patch<T, K extends keyof T> = Partial<T> & Pick<T, K>;
K‘s fields stay required; the rest are optional. A general utility.
Why splits matter: APIs often have required identifiers and optional updates. Modeling that directly with the type system makes the contract precise.
Why Pick + Partial<Omit<...>>: The two parts are disjoint. Pick selects the required fields; Partial<Omit<...>> makes the rest optional. Union with & and you have the split.
Why not use
Partial<T>alone: It makes everything optional. When some fields must be present, a split expresses that. The API rejects requests without the required fields โ the type should reflect that.
Pattern 5 โ Branded types
Brand a primitive to make it nominally distinct.
type UserId = string & { readonly __brand: 'UserId' };
type PostId = string & { readonly __brand: 'PostId' };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId): User { /* ... */ }
const userId = createUserId('u-1');
getUser(userId); // โ
// getUser('u-1'); // โ raw string
// getUser('p-1' as PostId); // โ wrong brand
The brand is a phantom property โ it doesn’t exist at runtime, but the type system sees it. A UserId and a PostId are both string structurally, but not assignable.
With a generic brand helper:
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type Age = Brand<number, 'Age'>;
function parseEmail(s: string): Email {
if (!s.includes('@')) throw new Error('Invalid email');
return s as Email;
}
Each call to parseEmail validates and returns a branded value. Consumers know they have an Email.
Why branded types matter: They catch mix-ups the compiler would otherwise miss. Passing a PostId where a UserId is expected is a real bug โ brands catch it.
Why the brand is a phantom: No runtime cost. The property doesn’t exist; the type system does the check. You can cast to the brand when you’ve validated the value.
Composing with unions:
type EntityId = UserId | PostId | CommentId;
function loadEntity(id: EntityId): Promise<unknown> {
// id is one of three branded strings
}
Brands compose with unions like any type.
Why “brand”: The value is marked with a brand โ like a stamp of origin.
UserIdis astringthat has been branded as a user ID. The compiler respects the brand; the runtime doesn’t care.
Pattern 6 โ Class-shaped interfaces
Extract the shape of a class as an interface.
class User {
constructor(
public id: number,
public name: string
) {}
greet(): string {
return `Hello, ${this.name}`;
}
}
type UserShape = {
id: number;
name: string;
greet: () => string;
};
UserShape describes anything with the same shape. A plain object literal works:
const mock: UserShape = {
id: 1,
name: 'Test',
greet() { return `Hi, ${this.name}`; }
};
Alternative โ derive from the class:
type UserInstance = InstanceType<typeof User>;
// User
But that’s the class instance type, not a structural shape. To get a shape:
type UserShape = {
[K in keyof User]: User[K];
};
Or simply use User as a type โ since TypeScript is structural, any matching object is assignable.
Why class-shaped interfaces matter: Testing needs mocks with the same shape. Interfaces decouple from the class and let any matching object stand in.
Why structural typing helps: TypeScript already treats classes structurally. A class instance is assignable to any compatible interface. No derivation needed for most cases.
When you do need extraction: When you can’t import the class (circular dependency, lazy loading) but need the shape. Extract the shape and depend on that.
Why interfaces over classes for contracts: Interfaces are erased at runtime. They don’t carry the class’s identity. Depend on the shape, and any implementation works.
Pattern 7 โ Readonly deep vs shallow
Readonly<T> is shallow. A deep version requires recursion.
type DeepReadonly<T> =
T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
Each level becomes readonly, recursively.
On a nested config:
interface Config {
server: {
host: string;
port: number;
};
features: string[];
}
type Frozen = DeepReadonly<Config>;
Frozen has every property and every nested property readonly.
On arrays:
type DeepReadonly<T> =
T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
Arrays become readonly arrays of deep-readonly elements.
The shallow alternative:
type ShallowFrozen = Readonly<Config>;
// Top-level readonly, nested mutable
When to use which: Shallow is faster and often enough. Deep when you need to promise total immutability โ in state management, as const, or configuration.
Why deep is expensive: It recurses into every property and array element. The compiler evaluates the recursion lazily, but on a large type the result is a large type. Use it where it matters.
Why
readonly DeepReadonly<U>[]: A readonly array of deep-readonly elements. Both the array and its contents are protected. Without the innerDeepReadonly, elements could still be mutated.
Pattern 8 โ Mapped dispatch tables
Build a lookup keyed by a union, with typed values per key.
type Handlers = {
[K in EventName]: (payload: EventPayloads[K]) => void;
};
For a union EventName and a mapping EventPayloads, the result is a table with one handler per event, each typed to its payload.
Concrete:
type Events = {
click: { x: number; y: number };
keydown: { key: string };
scroll: { offset: number };
};
type EventName = keyof Events;
type Handlers = {
[K in EventName]: (payload: Events[K]) => void;
};
// {
// click: (payload: { x: number; y: number }) => void;
// keydown: (payload: { key: string }) => void;
// scroll: (payload: { offset: number }) => void;
// }
Adding an event to Events requires adding a handler. The compiler catches the missing entry.
Combined with Partial for optional handlers:
type OptionalHandlers = Partial<Handlers>;
Combined with a class:
class EventBus {
private handlers: Partial<Handlers> = {};
on<K extends EventName>(
event: K,
handler: (payload: Events[K]) => void
): void {
this.handlers[event] = handler;
}
emit<K extends EventName>(event: K, payload: Events[K]): void {
this.handlers[event]?.(payload);
}
}
on and emit are typed against the event map. Passing the wrong payload is a compile error.
Why dispatch tables are idiomatic: They force exhaustive coverage. A missing handler is a compile error. Adding a new event breaks the table until handled.
Why mapped types are the tool: [K in EventName] iterates the union. Each key gets a typed value. That’s exactly a dispatch table.
Why the compiler catches missing handlers: A mapped type over a union requires an entry for each member. Omit one, and the type is incomplete โ the object literal fails to assign. That’s the check.
Pattern 9 โ Function wrappers
Wrappers use Parameters and ReturnType to preserve a function’s signature.
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>) => {
const key = JSON.stringify(args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
}) as T;
}
The wrapper has the same parameters and return type. Callers can’t tell it’s wrapped.
Common wrappers:
| Wrapper | Purpose |
|---|---|
memoize | Cache results |
debounce | Delay calls |
throttle | Rate-limit calls |
withRetry | Retry on failure |
withLogging | Log calls |
timed | Measure duration |
The signature:
function wrap<T extends (...args: any[]) => any>(fn: T): T {
return ((...args: Parameters<T>): ReturnType<T> => {
// pre
const result = fn(...args);
// post
return result;
}) as T;
}
The as T at the end tells TypeScript the wrapper matches the original. It’s necessary because the compiler can’t always prove the wrapper’s type is exactly T โ the Parameters and ReturnType preserve it, but the function identity changes.
Why wrappers matter: Higher-order functions โ middleware, decorators, higher-order components โ wrap others. The type utilities preserve the signature so no information is lost.
Why the as T: The wrapper’s body uses the same parameters and returns the same type, but TypeScript can’t fully verify the identity. The cast is safe because the utilities guarantee the shape.
Why not just type the wrapper explicitly: You’d have to write the signature twice โ once for the original, once for the wrapper. The utilities derive it from
T. No duplication.
Pattern 10 โ Recursive types
A recursive type references itself.
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
Json includes arrays of Json and objects with Json values. That’s a recursive definition.
Tree structures:
interface TreeNode<T> {
value: T;
children: TreeNode<T>[];
}
A tree is a value and a list of sub-trees.
Recursive mapped types:
type DeepPartial<T> =
T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
Each level is optional; objects recurse.
Why recursion is powerful: It expresses self-similar structures โ JSON, trees, ASTs, linked lists. The type mirrors the recursive structure of the data.
Why recursion needs a base case: Without it, the type never resolves. Json has primitives as the leaves; DeepPartial checks T extends object before recursing.
When recursion gets too deep: TypeScript limits recursion depth. A type that recurses too far errors. Restructure or cap the depth.
Why recursive types are common in real code: Trees appear everywhere โ DOM, JSON, ASTs, file systems. A recursive type describes them naturally without enumerating every level.
A full example
A small domain modeled with composition.
// ============================================
// ENTITIES
// ============================================
interface BaseEntity {
readonly id: string;
readonly createdAt: Date;
readonly updatedAt: Date;
}
interface User extends BaseEntity {
name: string;
email: string;
password: string;
role: 'admin' | 'user' | 'guest';
active: boolean;
}
interface Post extends BaseEntity {
title: string;
body: string;
authorId: string;
tags: string[];
published: boolean;
}
// ============================================
// VIEWS
// ============================================
type PublicUser = Omit<User, 'password'>;
type UserSummary = Pick<User, 'id' | 'name' | 'email' | 'role'>;
type PostPreview = Pick<Post, 'id' | 'title' | 'authorId' | 'tags'>;
// ============================================
// INPUT TYPES
// ============================================
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
type CreatePostInput = Omit<Post, 'id' | 'createdAt' | 'updatedAt'>;
type UpdatePostInput = Partial<Omit<Post, 'id' | 'createdAt' | 'updatedAt' | 'authorId'>>;
// ============================================
// RESULT TYPES
// ============================================
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// ============================================
// BRANDED IDS
// ============================================
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
// ============================================
// DISCRIMINATED STATE
// ============================================
interface RequestBase {
requestId: string;
startedAt: Date;
}
type RequestState<T> =
| (RequestBase & { status: 'loading' })
| (RequestBase & { status: 'success'; data: T })
| (RequestBase & { status: 'failure'; error: string });
// ============================================
// HANDLERS TABLE
// ============================================
type EventMap = {
userCreated: PublicUser;
userUpdated: PublicUser;
postPublished: PostPreview;
};
type EventName = keyof EventMap;
type EventHandlers = {
[K in EventName]: (payload: EventMap[K]) => void;
};
const handlers: EventHandlers = {
userCreated: user => console.log('created', user.name),
userUpdated: user => console.log('updated', user.name),
postPublished: post => console.log('published', post.title)
};
// ============================================
// DEEP READONLY
// ============================================
type DeepReadonly<T> =
T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// ============================================
// USAGE
// ============================================
const user: PublicUser = {
id: 'u-1',
name: 'Alice',
email: 'alice@example.com',
role: 'user',
active: true,
createdAt: new Date(),
updatedAt: new Date()
};
const created = ok(user);
if (created.ok) {
console.log(created.value.name);
}
handlers.userCreated(user);
handlers.postPublished({
id: 'p-1',
title: 'Hello',
authorId: 'u-1',
tags: ['intro'],
createdAt: new Date(),
updatedAt: new Date()
});
const frozen = user as DeepReadonly<PublicUser>;
// frozen.name = 'Bob'; // โ
What this shows:
- Views โ
PublicUser,UserSummary,PostPreview - Inputs โ
CreateUserInput,UpdateUserInputโPartial<Omit<...>> - Results โ
Result<T, E>โ discriminated union - Brands โ
UserId,PostIdโ nominal typing on primitives - States โ
RequestState<T>โ discriminated withBase & { ... } - Handlers โ mapped type over a union
- Deep readonly โ recursive mapped type
Each pattern is built from the utilities and operators from the earlier chapters. Composed, they model the whole domain.
Why this shape: It’s a small but real domain โ users, posts, requests, events. Every pattern appears. Every type derives from a source. Changing
Userupdates every view, input, and handler.
Complete Example Session
# ============================================
# PART 1: VIEW PROJECTIONS
# ============================================
cat > views.ts << 'EOF'
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
type PublicUser = Omit<User, 'password'>;
type UserSummary = Pick<User, 'id' | 'name'>;
const pub: PublicUser = {
id: 1,
name: 'Alice',
email: 'a@b.c',
createdAt: new Date()
};
const sum: UserSummary = { id: 1, name: 'Alice' };
console.log(pub, sum);
EOF
npx tsc --noEmit views.ts
# (no errors)
# ============================================
# PART 2: INPUT TYPES
# ============================================
cat > inputs.ts << 'EOF'
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
type CreateInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateInput = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
const create: CreateInput = { name: 'Alice', email: 'a@b.c' };
const update: UpdateInput = { name: 'Bob' };
console.log(create, update);
EOF
npx tsc --noEmit inputs.ts
# (no errors)
# ============================================
# PART 3: RESULT UNION
# ============================================
cat > result.ts << 'EOF'
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number> {
if (b === 0) return { ok: false, error: new Error('div by zero') };
return { ok: true, value: a / b };
}
const r = divide(10, 2);
if (r.ok) {
console.log(r.value); // number
} else {
console.log(r.error); // Error
}
EOF
npx tsc --noEmit result.ts
# (no errors)
# ============================================
# PART 4: BRANDED TYPES
# ============================================
cat > brands.ts << 'EOF'
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
function asUserId(s: string): UserId {
return s as UserId;
}
function asPostId(s: string): PostId {
return s as PostId;
}
function getUser(id: UserId): void {
console.log('user', id);
}
const uid = asUserId('u-1');
const pid = asPostId('p-1');
getUser(uid);
// getUser(pid); // โ
// getUser('u-1'); // โ
console.log(pid);
EOF
npx tsc --noEmit brands.ts
# (no errors)
# ============================================
# PART 5: REQUIRED + OPTIONAL SPLIT
# ============================================
cat > split.ts << 'EOF'
interface User {
id: number;
name: string;
email: string;
}
type UpdateWithId = Pick<User, 'id'> & Partial<Omit<User, 'id'>>;
const update: UpdateWithId = { id: 1, name: 'Alice' };
// const bad: UpdateWithId = { name: 'Alice' }; // โ missing id
console.log(update);
EOF
npx tsc --noEmit split.ts
# (no errors)
# ============================================
# PART 6: DISPATCH TABLE
# ============================================
cat > dispatch.ts << 'EOF'
interface Events {
click: { x: number; y: number };
keydown: { key: string };
}
type EventName = keyof Events;
type Handlers = {
[K in EventName]: (payload: Events[K]) => void;
};
const handlers: Handlers = {
click: p => console.log(p.x, p.y),
keydown: p => console.log(p.key)
};
handlers.click({ x: 1, y: 2 });
handlers.keydown({ key: 'Enter' });
EOF
npx tsc --noEmit dispatch.ts
# (no errors)
# ============================================
# PART 7: DEEP READONLY
# ============================================
cat > deep.ts << 'EOF'
type DeepReadonly<T> =
T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
server: { host: string; port: number };
features: string[];
}
const c: DeepReadonly<Config> = {
server: { host: 'localhost', port: 8080 },
features: ['a']
};
// c.server.host = 'x'; // โ
// c.features.push('b'); // โ
console.log(c);
EOF
npx tsc --noEmit deep.ts
# (no errors)
# ============================================
# PART 8: COMPILE AND RUN
# ============================================
npx tsc views.ts inputs.ts result.ts brands.ts split.ts dispatch.ts deep.ts
node views.js
# [ { id: 1, name: 'Alice', email: 'a@b.c', createdAt: ... } { id: 1, name: 'Alice' } ]
node inputs.js
# [ { name: 'Alice', email: 'a@b.c' } { name: 'Bob' } ]
node result.js
# [ 5 ]
node brands.js
# [ user u-1 ]
# [ p-1 ]
node split.js
# [ { id: 1, name: 'Alice' } ]
node dispatch.js
# [ 1 2 ]
# [ Enter ]
node deep.js
# [ { server: { host: 'localhost', port: 8080 }, features: [ 'a' ] } ]
Quick Reference
The Ten Patterns
| Pattern | Composition |
|---|---|
| View projection | Pick<T, K> or Omit<T, K> |
| Input type | Omit<T, ...> or Partial<Omit<T, ...>> |
| Discriminated result | { ok: true; v: T } | { ok: false; e: E } |
| Required/optional split | Pick<T, 'id'> & Partial<Omit<T, 'id'>> |
| Branded type | T & { readonly __brand: B } |
| Class-shaped interface | { [K in keyof C]: C[K] } |
| Deep readonly | Recursive readonly mapped type |
| Dispatch table | { [K in U]: F<T[K]> } |
| Function wrapper | <T extends Fn>(fn: T): T with Parameters/ReturnType |
| Recursive type | Self-referencing union or mapped type |
View Patterns
| Need | Pattern |
|---|---|
| Hide fields | Omit<T, 'secret'> |
| Few fields | Pick<T, 'a' | 'b'> |
| Add fields | Pick<T, 'a'> & { extra: string } |
| Full view | T |
Input Patterns
| Need | Pattern |
|---|---|
| Create | Omit<T, 'id' | 'createdAt'> |
| Update | Partial<Omit<T, 'id' | 'createdAt'>> |
| Patch | Partial<Pick<T, 'name' | 'email'>> |
| Required ID + optional | Pick<T, 'id'> & Partial<Omit<T, 'id'>> |
| Filter | Partial<Pick<T, 'role' | 'active'>> |
Discriminated Unions
| Form | Use |
|---|---|
{ ok: true; v: T } | { ok: false; e: E } | Result |
{ status: 'a' } | { status: 'b' } | State machine |
Base & { kind: 'x'; ... } | Factored variants |
{ type: 'ADD' } | { type: 'REMOVE' } | Actions |
Branded Types
| Form | Meaning |
|---|---|
T & { __brand: B } | Brand with a name |
Brand<T, B> | Helper alias |
as Brand | Cast to brand |
T & { readonly __brand: unique symbol } | Unforgeable brand |
Mapped Type Patterns
| Form | Purpose |
|---|---|
{ [K in keyof T]: T[K] } | Identity |
{ [K in keyof T]?: T[K] } | Optional |
{ readonly [K in keyof T]: T[K] } | Readonly |
{ [K in U]: F<T[K]> } | Dispatch |
{ [K in keyof T as \get${Capitalize}`]: … }` | Rename |
Recursive Patterns
| Type | Recursion |
|---|---|
DeepReadonly<T> | T extends object ? { readonly [K]: DeepReadonly } : T |
DeepPartial<T> | T extends object ? { [K]?: DeepPartial } : T |
Json | | Json[] | { [k: string]: Json } |
TreeNode<T> | { value: T; children: TreeNode<T>[] } |
Wrapper Patterns
| Wrapper | Utilities |
|---|---|
| Memoize | Parameters<T>, ReturnType<T> |
| Debounce | Same |
| Retry | Same + Promise |
| Logging | Same |
| Timing | Same |
Composition Rules
| Rule | Detail |
|---|---|
| Define source first | Entities as interfaces |
| Derive everything else | Views, inputs, results |
| Use intersections to add | Base & { extra } |
Use Partial for optionality | Partial<Omit<T, ...>> |
Use Pick for inclusion | Shorter than Omit for few fields |
Use Omit for exclusion | Shorter for few exceptions |
| Compose utilities | Partial<Omit<T, ...>> |
| Never duplicate properties | Derive from the source |
Anti-Patterns
| Anti-pattern | Problem |
|---|---|
| Duplicated interfaces | Drift |
| Optional when required | Loses guarantees |
any to silence errors | Erases safety |
| Deep nesting without names | Unreadable |
| Brands on every type | Overhead |
Omit<T, never> for identity | Confusing |
Cross-cutting Partial | Wrong shape |
Best Practices
โ Do This:
// Define source entities as interfaces
interface User { id: number; name: string; email: string; password: string; } // โ
// Derive views with Pick / Omit
type PublicUser = Omit<User, 'password'>; // โ
// Derive inputs with Partial + Omit
type UpdateInput = Partial<Omit<User, 'id'>>; // โ
// Model results as discriminated unions
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }; // โ
// Use branded types for IDs
type UserId = Brand<string, 'UserId'>; // โ
// Factor shared fields into a Base
type Success<T> = Base & { status: 'success'; data: T }; // โ
// Use mapped types for dispatch tables
type Handlers = { [K in EventName]: (p: EventMap[K]) => void }; // โ
// Wrap functions preserving signatures
function memoize<T extends Fn>(fn: T): T { } // โ
// Recursive types for trees and JSON
type Json = string | number | Json[] | { [k: string]: Json }; // โ
// Separate required and optional
type Update = Pick<T, 'id'> & Partial<Omit<T, 'id'>>; // โ
โ Don’t Do This:
// Don't duplicate entity properties in views
interface PublicUser { id: number; name: string; email: string; } // โ ๏ธ // โ ๏ธ
// Don't make everything optional when some fields are required
type Bad = Partial<User>; // โ ๏ธ loses required fields // โ ๏ธ
// Don't use `any` in compositions
type Bad = Pick<User, 'id'> & { x: any }; // โ ๏ธ // โ ๏ธ
// Don't nest utilities without naming
type Complex = Partial<Required<Pick<Omit<User, 'id'>, 'name'>>>; // โ ๏ธ // โ ๏ธ
// Don't over-brand
type Name = Brand<string, 'Name'>; // โ ๏ธ every string needs a brand // โ ๏ธ
// Don't use `T | T` in unions
type Bad = { a: 1 } | { a: 1 }; // โ ๏ธ duplicates collapse // โ ๏ธ
// Don't forget base cases in recursive types
type Bad<T> = T extends object ? Bad<T> : T; // โ ๏ธ infinite // โ ๏ธ
// Don't share types across unrelated domains
type Both = User & Post; // โ ๏ธ rarely makes sense // โ ๏ธ
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Duplicated interfaces | Drift | Derive with Pick/Omit |
Partial everywhere | Lost guarantees | Split required and optional |
any in composition | Erased safety | Use proper types |
| Over-nesting utilities | Unreadable | Extract named types |
| Branding too much | Overhead | Brand only where confusion is likely |
| Recursion without base | Infinite type | Add a base case |
| Intersections that conflict | never property | Align types |
| Discriminant not literal | No narrowing | Use literal types |
Missing string & K | Compile error | Add it |
| Forgetting readonly | Mutable promise broken | Add readonly |
Real-World Examples
1. Public view
type PublicUser = Omit<User, 'password'>;
2. Summary view
type UserSummary = Pick<User, 'id' | 'name'>;
3. Create input
type CreateInput = Omit<User, 'id' | 'createdAt'>;
4. Update input
type UpdateInput = Partial<Omit<User, 'id' | 'createdAt'>>;
5. Required ID + optional
type Update = Pick<User, 'id'> & Partial<Omit<User, 'id'>>;
6. Result type
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
7. State machine
type State = { status: 'idle' } | { status: 'loading' } | { status: 'ready'; data: T };
8. Branded ID
type UserId = string & { readonly __brand: 'UserId' };
9. Brand helper
type Brand<T, B> = T & { readonly __brand: B };
10. Deep readonly
type DeepReadonly<T> =
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
11. Deep partial
type DeepPartial<T> =
T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
12. Dispatch table
type Handlers = { [K in EventName]: (p: Events[K]) => void };
13. Function wrapper
function wrap<T extends Fn>(fn: T): T { /* ... */ }
14. Memoize
function memoize<T extends Fn>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>) => {
const k = JSON.stringify(args);
return cache.get(k) ?? (cache.set(k, fn(...args)), cache.get(k));
}) as T;
}
15. Result helpers
function ok<T>(value: T): Result<T> { return { ok: true, value }; }
function err<E>(error: E): Result<never, E> { return { ok: false, error }; }
16. Json type
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
17. Tree
interface Tree<T> {
value: T;
children: Tree<T>[];
}
18. Accessors
type Accessors<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
} & {
[K in keyof T as `set${Capitalize<string & K>}`]: (v: T[K]) => void;
};
19. Route params
type Params<S> = S extends `${string}:${infer P}/${infer Rest}`
? P | Params<`/${Rest}`>
: S extends `${string}:${infer P}`
? P
: never;
20. Discriminated event
type Event =
| { kind: 'click'; x: number; y: number }
| { kind: 'keydown'; key: string };
Visual: View Projection
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Entity: โ
โ { โ
โ id: number; โ
โ name: string; โ
โ email: string; โ
โ password: string; โ
โ createdAt: Date; โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโบ Omit<User, 'password'>
โ โ public view
โ
โโโโบ Pick<User, 'id' | 'name'>
โ โ summary
โ
โโโโบ Omit<User, 'password' | 'createdAt'>
โ create input
Visual: Discriminated Union
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Result<T, E> = โ
โ | { ok: true; value: T } โ
โ | { ok: false; error: E }; โ
โ โ
โ if (r.ok) { โ
โ r.value โ narrowed โ
โ
โ } else { โ
โ r.error โ narrowed โ
โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Request state: โ
โ โ
โ type State<T> = โ
โ | { status: 'loading' } โ
โ | { status: 'success'; data: T } โ
โ | { status: 'error'; message: string }; โ
โ โ
โ switch (state.status) { ... } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Branded Type
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ string โ
โ โ any string โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ Brand<string, 'UserId'>
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ string & { readonly __brand: 'UserId' } โ
โ โ
โ A string with a phantom brand โ
โ Runtime: just a string โ
โ Compile-time: distinct from other strings โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UserId โโ not assignable to PostId โ
โ PostId โโ not assignable to UserId โ
โ string โโ not assignable to UserId โ
โ UserId โโ assignable to string โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Dispatch Table
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Events = { โ
โ click: { x: number; y: number }; โ
โ keydown: { key: string }; โ
โ }; โ
โ โ
โ type Handlers = { โ
โ [K in keyof Events]: (p: Events[K]) => void;โ
โ }; โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ { โ
โ click: (p: { x: number; y: number }) => void;โ
โ keydown: (p: { key: string }) => void; โ
โ } โ
โ โ
โ Missing a key โ compile error โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Function Wrapper
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ function memoize<T extends Fn>(fn: T): T { โ
โ const cache = new Map<string, ReturnType<T>>();โ
โ return ((...args: Parameters<T>) => { โ
โ // ... โ
โ }) as T; โ
โ } โ
โ โ
โ T โ wrapper's type โ
โ Parameters<T> โ wrapper's args โ
โ ReturnType<T> โ wrapper's return โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ const fn = (a: number, b: number) => a + b; โ
โ const cached = memoize(fn); โ
โ โ
โ cached(1, 2); // (a: number, b: number) => numberโ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Deep Readonly
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ interface Config { โ
โ server: { host: string; port: number }; โ
โ features: string[]; โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ DeepReadonly
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ { โ
โ readonly server: { โ
โ readonly host: string; โ
โ readonly port: number; โ
โ }; โ
โ readonly features: readonly string[]; โ
โ } โ
โ โ
โ Every level protected โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Required + Optional Split
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ type Update = โ
โ Pick<User, 'id'> & Partial<Omit<User, 'id'>>;โ
โ โ
โ Result: โ
โ { โ
โ id: number; โ required โ
โ name?: string; โ optional โ
โ email?: string; โ optional โ
โ } โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Valid: โ
โ { id: 1 } โ
โ
โ { id: 1, name: 'Alice' } โ
โ
โ โ
โ Invalid: โ
โ { name: 'Alice' } โ missing id โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Visual: Pattern Decision Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Multiple representations of an entity? โ
โ โโโ View projections (Pick / Omit) โ
โ โ
โ Input for an API? โ
โ โโโ Input types (Omit + Partial) โ
โ โ
โ One of several outcomes? โ
โ โโโ Discriminated union โ
โ โ
โ Required identifier + optional fields? โ
โ โโโ Pick & Partial<Omit> โ
โ โ
โ Primitive with semantic meaning? โ
โ โโโ Branded type โ
โ โ
โ Lookup keyed by a union? โ
โ โโโ Mapped dispatch table โ
โ โ
โ Wrapping a function? โ
โ โโโ Parameters / ReturnType โ
โ โ
โ Nested structure? โ
โ โโโ Recursive type โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Pattern | Purpose |
|---|---|
| View projection | Derive views from the entity |
| Input type | Create / update / patch inputs |
| Discriminated union | Result, state, action variants |
| Required/optional split | Required ID + optional fields |
| Branded type | Nominal typing for primitives |
| Class-shaped interface | Structural extraction |
| Deep readonly | Recursive immutability |
| Dispatch table | Keyed handlers |
| Function wrapper | Preserve signatures |
| Recursive type | Trees, JSON, ASTs |
Key takeaways:
- Composition is factoring โ define the source once, derive everything else
- View projections โ
PickandOmitโ derive representations for different audiences - Input types โ
Omit<T, ...>andPartial<Omit<T, ...>>โ derive create and update payloads - Discriminated unions โ literal discriminants โ model results, states, and actions
Base & { ... }factors shared fields out of union variants- Required/optional split โ
Pick<T, 'id'> & Partial<Omit<T, 'id'>>โ for PATCH APIs - Branded types โ
T & { __brand: B }โ make primitives nominally distinct - Dispatch tables โ mapped types over unions โ force exhaustive coverage
- Function wrappers โ
Parameters<T>andReturnType<T>โ preserve signatures - Recursive types โ trees, JSON, ASTs โ model self-similar structures
- Deep readonly and deep partial โ recursion with a base case
- The utilities are the tools; the patterns are the recipes
- Never duplicate properties โ derive from the source
- Name your derived types โ they document intent
Remember: Type composition is the practice of building types from other types. Every utility in this chapter โ Pick, Omit, Partial, Record, Exclude, Extract, ReturnType, Parameters, infer, template literals โ is a building block. The patterns โ views, inputs, results, brands, states, wrappers, tables โ are the compositions that matter in real code. Define the source once, derive the rest, and let the compiler keep everything in sync. That’s the whole craft.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!