This article is published in English.
Choosing TypeScript Patterns by the Complexity They Remove
A tour of classic design patterns and type-level TypeScript techniques, with clear guidance on when each one earns its place and when plain code wins.
Most teams adopt TypeScript for autocomplete and typo detection, then discover its real value: it lets you encode design decisions so the compiler enforces them. This guide walks through the classic object-oriented patterns and the type-level techniques that matter most in large frontend and full-stack codebases, and shows how to decide when a pattern is worth its cost.
Think of the type system as something that can answer architectural questions:
- Is this combination of state values actually possible?
- Could this API hand back a shape the rest of the code does not expect?
- Can a
ProductIdslip into a function that wants aUserId? - Can a component be given props that contradict each other?
- If a new state is added, will every consumer be forced to handle it?
- Can an implementation be swapped without touching its callers?
- Can an abstraction be reused without falling back to
any?
A pattern is not a feature you bolt on. It is a name for a solution you recognize once a recurring problem shows up.
Mapping the landscape
The classic split covers creational, structural and behavioral patterns; TypeScript adds a fourth family of type-level techniques built on generics, unions and safety tools.
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
Real applications rarely use one pattern alone. A typical React data flow stacks several of them, and every boundary can carry precise types:
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
When types flow through that whole chain, TypeScript becomes a description of how the system is allowed to behave.
Start from the problem, not the pattern
A common trap is reasoning in the wrong direction:
"I know Factory Pattern.
Where can I use Factory?"
Picking a pattern first produces abstractions nobody needed. Let the problem drive the choice instead:
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
Consider a payment flow that has grown a series of checks on the payment method:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
The reflex response is often this:
Don’t immediately think:
"This needs Strategy." Before reaching for it, ask whether the branches really are interchangeable algorithms behind one contract. If they are, Strategy fits. If the real problem is that some fields only make sense for some methods, a discriminated union that makes invalid combinations unrepresentable may be the better tool. Knowing the catalog is easy; matching an entry to a specific pressure is the skill.
Creational patterns
Singleton: one shared instance
A Singleton ensures a class has exactly one instance. The private constructor blocks outside new, and a static method lazily creates and caches the instance:
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
Callers ask for the shared object instead of constructing one:
const logger = Logger.getInstance();
logger.log("Application started");
Every consumer ends up pointing at the same object:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Reasonable candidates include:
- logging
- analytics managers
- configuration holders
- some connection managers
- other cross-cutting infrastructure services
The catch is that a Singleton is global access in disguise: tests become harder to isolate, dependencies vanish from signatures, lifecycle gets murky, and shared mutable state changes in untraceable ways. In a modern frontend, an instance exported from an ES module is already shared, and dependency injection, React Context or a state library give the same guarantee with visible wiring.
Factory: hide which class gets built
A Factory takes the decision of which concrete class to instantiate away from the consumer. Compare direct construction:
const payment = new StripePayment();
with delegated construction:
const payment = PaymentFactory.create("stripe");
Both providers implement one interface, and the factory maps a string literal union to the right class. Because the parameter type is "stripe" | "paypal", an unsupported name fails to compile:
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
Visually, the factory is a fork keyed by the provider name:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
A factory pays off when:
- construction involves real logic
- several implementations share one contract
- consumers should not know concrete classes
- implementations must evolve independently of callers
For trivial construction like the line below, it only adds a layer to read through:
new User();
Abstract Factory: families of related objects
Abstract Factory extends the idea to groups of objects that must match. In a UI kit targeting several platforms, a web button should never be paired with a mobile modal, so each factory produces one consistent family:
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
It is powerful and easy to overbuild. In most frontend applications, plain component composition gets you there with less ceremony.
Builder: controlled, step-by-step construction
Builder helps when an object has many optional settings or is configured in stages. Each setter updates private config and returns this, enabling chaining:
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
Assembling a request reads as explicit steps:
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
Fluent syntax is a side effect, not the goal. The point is to keep complicated construction explicit and in one place, where build() can also validate, for example refusing a body on a GET request.
Structural patterns
Adapter: a stable contract around someone else's API
Adapter is one of the most frequently used patterns in application code. Suppose your code expects this internal contract:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
while a third-party SDK exposes a differently named method:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
A thin adapter implements your interface and translates the calls:
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
The application now depends on one interface you own:
PaymentGateway
rather than on every vendor behind it:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Supporting a new provider means writing another adapter.
Facade: one call for a multi-step workflow
A Facade puts a simple entry point in front of a complicated subsystem. Logging in might touch several services:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Without a facade, every component that logs a user in orchestrates the sequence itself:
auth.login();
user.load();
permission.load();
analytics.track();
A facade wraps that orchestration once:
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
and the component shrinks to one call:
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
This shines when step order matters.
Decorator: add behavior by wrapping
A Decorator adds behavior without changing the original implementation, because wrapper and wrapped object share an interface. The contract:
interface Logger {
log(message: string): void;
}
A plain implementation:
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
A decorator that holds any Logger and prefixes messages with a timestamp:
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
Wrapping is just construction:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Since each decorator is itself a Logger, they stack:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
The same idea appears under other names:
- middleware chains
- wrapper functions
- React Higher-Order Components
- logging
- caching layers
- authorization checks
- instrumentation and tracing
Behavioral patterns
Strategy: interchangeable algorithms
Strategy removes branching business rules. Take a customer tier type:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
A conditional implementation puts every rule in one function:
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
With Strategy, each rule becomes a class behind a shared interface:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
Adding a tier now usually means adding a strategy rather than editing existing logic, which is the Open/Closed Principle in practice. For three tiny rules the conditional is fine; Strategy pays off once rules grow their own dependencies or tests.
Observer: one-to-many notifications
Observer lets one subject notify any number of listeners when something changes:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
A small typed emitter stores listeners in a Set and returns a cleanup function when a listener is registered:
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
That cleanup function is lifecycle management. Forgetting to call it leads to:
- memory leaks from listeners that outlive their owners
- duplicated handling when a listener is attached twice
- stale closures reading outdated values
- side effects firing after a component is gone
In React, it is exactly what you return from a useEffect callback.
Command: actions as objects
Command turns an action into an object with a uniform interface:
interface Command {
execute(): void;
}
Concrete commands implement it:
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
Treating actions as values helps when you need:
- a history of what happened
- undo and redo
- queued execution
- retries
- deferred execution
- audit logging
Real undo usually requires each command to reverse itself, so production versions often add an undo() method next to execute().
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
Data and composition patterns
Repository: isolate data access
Once data access becomes non-trivial, a Repository places a contract between business logic and whatever stores or fetches the data:
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
The contract describes what the app can ask for:
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
One implementation talks to a REST API:
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
Business code depends on the interface:
UserRepository
not on a transport:
fetch()
axios()
graphqlClient()
This matters when the source changes: REST to GraphQL, an added IndexedDB cache, or an in-memory fake for tests. Note that response.json() returns an untyped value, so the repository is also the right place to validate responses.
Composition over inheritance
In frontend work, composition matters more than any inheritance-based pattern. Instead of one component that owns everything:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
split responsibilities into focused pieces:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
In React this is simply nesting components:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Each piece can be understood, tested and replaced on its own. For more on breaking up bloated components, see fixing React prop overload with composition and slots.
Modelling state with the type system
From here on, TypeScript itself is the architectural tool.
Discriminated unions for request state
A request moves through phases that carry different data. A discriminated union gives each phase its own shape, tied together by a literal status field:
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
Switching on the discriminant narrows the type in each branch:
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
The compiler tracks which fields exist after each check:
status = "success"
↓
data exists
status = "error"
↓
error exists
Compare the common boolean-and-optionals model:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Nothing stops it from describing a state that should never occur:
{
loading: true,
data: [...],
error: "Something failed"
}
The union makes such combinations very hard to express. Model the valid states instead of scattering optional properties and trusting everyone to combine them correctly.
Result types for explicit failure
Many operations have exactly two outcomes:
Success
OR
Failure
A Result type spells both out with a boolean discriminant:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
A function returning it:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
The caller must check success before touching data or error:
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
This fits expected business failures such as "email already registered". Exceptions still suit genuine bugs and infrastructure failures; the Result type just makes anticipated failures visible in the signature.
Generics and type-level tools
Generics preserve the link between input and output
Using any throws information away:
function identity(value: any): any {
return value;
}
A type parameter keeps it:
function identity<T>(value: T): T {
return value;
}
The inferred result follows the argument:
const a = identity("hello");
// string
const b = identity(100);
// number
The relationship between input and output survives:
Input T
│
▼
Function<T>
│
▼
Output T
Generics also make shared contracts reusable. One response envelope:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
describes many payloads:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Constraints: requiring a shape
This fails to compile:
function getId<T>(item: T) {
return item.id;
}
because nothing tells TypeScript that T has an id. A constraint adds that guarantee:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Any object with a string id is accepted, extra fields included:
getId({
id: "123",
name: "Hareesh"
});
Read the constraint like this:
T can be anything
BUT
T must have id: string
keyof and indexed access
Given an interface:
interface User {
id: string;
name: string;
age: number;
}
keyof produces the union of its property names:
type UserKey = keyof User;
"id" | "name" | "age"
Combining keyof with a second type parameter and the indexed access type T[K] yields an accessor whose return type matches the key:
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
A real key compiles:
getProperty(user, "name");
A missing key is a compile error:
getProperty(user, "salary");
The strength comes from three tools working together:
Generics
+
keyof
+
Indexed Access
Mapped types: transform an existing type
Mapped types iterate over a type's keys to build a new type. Starting from:
interface User {
id: string;
name: string;
email: string;
}
you can derive an all-optional version:
type OptionalUser = {
[K in keyof User]?: User[K];
};
conceptually equal to writing:
{
id?: string;
name?: string;
email?: string;
}
This is how built-ins such as Partial are defined.
Conditional types: decisions at the type level
Conditional types choose between two types based on assignability:
T extends U ? X : Y
This one unwraps array element types and leaves everything else alone:
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
At this point the type system behaves like a small compile-time language, which calls for restraint.
infer: extract part of a type
Inside a conditional type, infer declares a type variable that TypeScript fills in by matching. This re-implements the built-in ReturnType:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Pair it with typeof to derive a type from an existing function, so it can never drift from the implementation:
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
Library type definitions rely heavily on this.
Built-in utility types first
Before writing clever helpers, know what ships with the language:
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
Removing a sensitive field, for example, is one line instead of a duplicated interface that will fall out of sync:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
The guide to TypeScript's built-in utility types covers each of these in depth.
Record with a closed set of keys
Record suits lookups and configuration, especially when keys come from a literal union:
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
Leave out a permission and TypeScript reports the missing key. A wide key type loses that guarantee:
const permissions: Record<string, boolean>
because Record<string, boolean> accepts practically any string key, so omissions and typos go unnoticed.
Safety patterns for identifiers and untrusted data
Branded types for domain identifiers
Two identifiers can both be strings yet mean different things:
const userId: string;
const productId: string;
Structurally, TypeScript cannot tell them apart. Intersecting string with a phantom property creates distinct types:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Functions can then demand the right kind of ID:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Passing a ProductId where a UserId is expected becomes a compile error. The brand never exists at runtime; you create branded values through one small constructor or validation function that casts in a single place. Conceptually:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
This pays off in large systems where dozens of identifiers share one primitive type.
Type guards for unknown input
Data from the network, storage or user input should enter as unknown:
const data: unknown = await response.json();
A cast is the tempting shortcut:
const user = data as User;
Narrow the value with a user-defined type guard instead; its value is User return type tells the compiler what a true result proves:
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
Remember that types are erased at runtime. An interface like this:
interface User {
id: string;
}
validates nothing the API sends. The guard above only checks that keys exist, not their types, so for untrusted input pair TypeScript with a runtime schema validator.
Exhaustive checking with never
One of the most effective combinations in the language:
Discriminated Union
+
never
+
switch
Take a status union:
type Status =
| "loading"
| "success"
| "error";
and a helper that only accepts never:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
When every member is handled, the default branch sees type never, so the call compiles:
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
Now a teammate extends the union:
Now imagine someone adds:"cancelled" to Status.
The default branch receives "cancelled", which is not assignable to never, and the build fails until the case is handled. The compiler becomes a design reviewer that remembers every consumer.
Template literal types
TypeScript can compose string literal types from other types:
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
The resulting union contains every combination:
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
Practical uses include:
- event names
- analytics event keys
- permission strings
- route patterns
- feature flag names
- design system tokens
as const when values are the source of truth
An array literal of strings is widened:
const roles = [
"admin",
"editor",
"viewer"
];
Its type is string[]. Adding as const produces a readonly tuple of literals:
const roles = [
"admin",
"editor",
"viewer"
] as const;
from which a union type follows directly:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Define the values once and never maintain a parallel union by hand.
satisfies for checked configuration
satisfies validates an expression against a type without widening it to that type:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
The object is checked against Config, yet config.environment keeps the literal type "production", which a plain annotation would lose. It suits:
- route configuration
- feature flags
- design tokens
- static settings objects
- permission maps
Dependency injection
Creating dependencies inside a class welds it to them:
class UserService {
private api = new ApiClient();
}
Receiving them through the constructor keeps the class agnostic:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
Production passes the real client:
const service =
new UserService(apiClient);
and tests a mock:
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
It is one of the cheapest routes to testable code, and constructor parameters are all it needs.
Telling similar patterns apart
State pattern or discriminated union?
Given an order lifecycle:
Draft
Paid
Shipped
Cancelled
a discriminated union may be all you need:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
But when every state carries substantial behavior:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
a State pattern, where each state object implements the allowed operations, may fit better. Decide by how much complexity each state holds, not by terminology.
Strategy or State?
A frequent interview topic. With Strategy, the client picks the algorithm:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
With State, the object changes its own behavior as it moves through its lifecycle:
Order
│
├── Draft
├── Paid
└── Shipped
Strategy varies how a task is performed; State varies what an object does at its current stage.
Factory or Strategy?
A Factory answers "which object should be created?":
PaymentFactory.create("stripe");
A Strategy answers "which behavior should run?":
new Order(discountStrategy);
They combine naturally, a factory producing the strategy that does the work:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Applying the patterns in React
Variant props instead of boolean flags
Independent booleans allow nonsense such as a button that is both primary and danger:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
A union of variants lets each declare its own requirements:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
The danger variant must now specify confirmationRequired.
Component APIs that reject contradictions
A component that renders either a link or a button with optional href and onClick would allow both or neither. The snippet below shows the loose version, the discriminated alternative and a valid usage:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
A link variant given a click handler instead of an href is rejected:
<Action
type="link"
onClick={...}
/>
The supported modes, stated plainly:
Link
Button
TypeScript is now part of the component architecture rather than documentation that goes stale.
A type-safe event bus
Start with a map from event names to payload types:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
A bus generic over that map ties each name to its payload through keyof and T[K]:
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
The correct payload compiles:
bus.emit("user:created", user);
The wrong one does not:
bus.emit("user:created", order);
Several tools meet here:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Types across the whole API layer
A mature frontend often layers data access like this:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
with types carried through every step. A repository can accept branded IDs and return a Result:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
The component no longer deals with this at every layer:
any
The signatures themselves say what can succeed, what can fail and with which types.
Anti-patterns to avoid
any everywhere
function process(data: any) {}
An any parameter switches off checking for everything it touches. Prefer:
function process(data: unknown) {}
and narrow before use.
Assertions as validation
const user =
response.data as User;
An as cast checks nothing; it only asks the compiler to trust you. Validate untrusted data at runtime.
Generics for their own sake
Avoid signatures like:
function transform<
T,
U,
V,
R
>(...) {}
unless each type parameter expresses a real relationship. A type parameter used once is usually unnecessary.
Enormous unions
A discriminated union with a hundred members is hard to navigate and evolve. The domain may need a different abstraction, such as nested unions or moving variation into data.
Type-level cleverness
When a type is harder to follow than the business logic it describes, step back:
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
Type safety has a cost. Aim for the most useful safety per unit of complexity, not the most sophisticated types.
A decision tree for choosing
This tree maps common pressures to candidate patterns:
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
Treat it as a starting point for discussion; "start simple" still applies at every leaf.
What to learn first
For frontend engineers preparing for senior or lead roles, memorizing every Gang of Four pattern is poor use of time. A practical order:
The essentials:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Strongly recommended next:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Worth understanding conceptually:
Builder
Command
State
Abstract Factory
Singleton
Day-to-day frontend work leans far more on this combination:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
than on any textbook Abstract Factory.
How judgement shifts with experience
Early on, engineers ask "which pattern should I use here?" With experience on large systems, the question becomes "what is the smallest abstraction that solves this without making the system harder to understand?" The pattern-first workflow looks like this:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
The problem-first workflow looks like this:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
Good patterns emerge as the architecture's pressures become clear; they are not imposed ahead of time.
Interview questions that probe real understanding
"What is the Factory pattern?" tests recall. These test judgement.
Architecture:
- Faced with a 2,000-line React component, how do you choose which abstraction to introduce?
- When would you turn down a pattern that seems to fit?
- How can you tell an abstraction is premature?
- When is composition preferable to inheritance?
- How do you keep a Singleton from becoming global mutable state?
TypeScript fundamentals:
- How would you model a request with loading, success, error and retry states?
- How do you stop invalid combinations of React props?
- How do
unknown,anyandneverdiffer? - When would you pick
typeoverinterface, and vice versa? - How does
keyofinteract with generics?
Advanced TypeScript:
- What is a concrete use case for conditional types?
- What does
inferdo? - What is a mapped type?
- What are distributive conditional types?
- When are branded types worth it?
- Which problem does
satisfiessolve? - How does
as constchange inference?
Real-world design:
- Design a type-safe event bus.
- Design a type-safe API client.
- Design a permission system using TypeScript.
- Design a payment abstraction supporting Stripe, PayPal and a third provider.
- How would you add a Repository layer to an existing React app without a rewrite?
- How would you type WebSocket events with different payloads?
- How would you stop a
ProductIdbeing passed where aUserIdis required?
A revealing one: "Describe a time you deliberately chose not to use a design pattern." A strong answer explains the trade-off: with a single implementation and no likely change to guard against, the indirection would not have reduced complexity, so the code stayed simple until a second use case justified it.
A final mental model
Start from the business problem, find where the complexity lives, separate behavioral change from structural change, then let type safety tighten the result.
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
Key takeaways
Patterns work best as shared vocabulary: "this is an adapter", "these behaviors are interchangeable", "these states belong in a union", "brand these IDs", and, most valuably, "this abstraction has not earned its complexity yet." Good TypeScript is judged by whether the system has these properties, not by how clever its types are:
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- Pick patterns by the complexity they remove, not by familiarity.
- Prefer unions,
Resulttypes and exhaustive checks to optional fields and hope. - Validate data at runtime boundaries; types alone do not protect you there.
- Reach for the simplest abstraction that handles the change you actually expect.