TypeScript Wizardry: 7 Patterns That Will Make You Irreplaceable
Most developers reach for TypeScript and immediately type everything as string, number, and boolean. They add type annotations to make the IDE happy, and that's where their TypeScript journey ends. These developers have a type-annotated JavaScript codebase. That's not the same as a TypeScript codebase.
Real TypeScript is about making illegal states unrepresentable — writing types so precise that whole categories of bugs become compile errors instead of runtime crashes. Here are the 7 patterns that will transform how you write code.
1. Discriminated Unions: Goodbye to Boolean Soup
Most developers model async state like this:
// ❌ The boolean soup anti-pattern
type UserState = {
isLoading: boolean;
isError: boolean;
data?: User;
error?: Error;
};
// This is legal TypeScript but logically impossible:
const broken: UserState = { isLoading: true, isError: true, data: someUser };
The problem: 4 booleans create 16 possible states, but only 3 are valid. TypeScript can't enforce this.
// ✅ Discriminated union — only valid states exist
type UserState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
// TypeScript now enforces exhaustive handling:
function renderUser(state: UserState) {
switch (state.status) {
case "idle": return <WelcomeScreen />;
case "loading": return <Spinner />;
case "success": return <UserCard user={state.data} />; // data is guaranteed here
case "error": return <ErrorBanner error={state.error} />; // error is guaranteed here
// TS will error if you add a new status and forget to handle it
}
}
Insight
If your type has two or more boolean flags, it's almost certainly a candidate for a discriminated union. The discriminant (the status field) is the key — it narrows the type in every branch, eliminating all the ?. optional chaining.
2. Branded Types: Make Invalid IDs Impossible
Ever passed a userId where an orderId was expected? Both are string, so TypeScript happily accepts the mistake.
// ❌ TypeScript can't catch this bug
function getOrder(orderId: string) { /* ... */ }
const userId = "usr_123";
getOrder(userId); // compiles fine, runtime disaster
// ✅ Branded types make IDs non-interchangeable
type UserId = string & { readonly _brand: "UserId" };
type OrderId = string & { readonly _brand: "OrderId" };
// Constructor functions that "brand" the value
function toUserId(id: string): UserId { return id as UserId; }
function toOrderId(id: string): OrderId { return id as OrderId; }
function getOrder(orderId: OrderId) { /* ... */ }
const userId = toUserId("usr_123");
getOrder(userId); // ✅ TypeScript ERROR: Argument of type 'UserId' is not assignable to 'OrderId'
3. Template Literal Types: Type-Safe Strings
// Generate all valid CSS spacing values
type SpacingScale = 1 | 2 | 4 | 6 | 8 | 12 | 16 | 24 | 32;
type TailwindSpacing = `p-${SpacingScale}` | `m-${SpacingScale}` | `gap-${SpacingScale}`;
// Type-safe event names
type EventMap = {
"user:login": { userId: string };
"user:logout": { userId: string };
"order:placed": { orderId: string; total: number };
};
type EventName = keyof EventMap; // "user:login" | "user:logout" | "order:placed"
function emit<T extends EventName>(event: T, payload: EventMap[T]) {
// Fully type-safe event emission
}
emit("user:login", { userId: "123" }); // ✅
emit("order:placed", { total: 99 }); // ❌ TS ERROR: missing orderId
4. Conditional Types: Type-Level if/else
// Extract the resolved value from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Make specific properties required
type RequireFields<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
type Blog = {
id?: string;
title?: string;
content?: string;
publishedAt?: Date;
};
// A published blog MUST have all fields
type PublishedBlog = RequireFields<Blog, "id" | "title" | "content" | "publishedAt">;
function publishBlog(blog: PublishedBlog) {
// All fields guaranteed non-undefined here
console.log(blog.title.toUpperCase()); // ✅ safe
}
Tip
The infer keyword inside conditional types is one of TypeScript's most powerful features. It lets you "extract" a type from within another type — like destructuring, but at the type level.
5. Satisfies: Validation Without Inference Override
// ❌ Old way: type annotation loses specific information
const config: Record<string, string> = {
apiUrl: "https://api.example.com",
wsUrl: "wss://ws.example.com",
};
config.apiUrl; // type: string (lost the specific value)
// ✅ satisfies: validates the type, keeps the specific types
const config = {
apiUrl: "https://api.example.com",
wsUrl: "wss://ws.example.com",
} satisfies Record<string, string>;
config.apiUrl; // type: "https://api.example.com" (specific string literal)
// Real power: catch typos while keeping autocomplete
const routes = {
home: "/",
blog: "/blog",
abuot: "/about", // ❌ TypeScript catches the typo if you validate the shape
} satisfies Record<"home" | "blog" | "about", string>;
6. Function Overloads: Polymorphism Done Right
// ❌ The any-return anti-pattern
function formatDate(date: Date | string, format?: string): any { /* ... */ }
// ✅ Overloads with precise return types
function formatDate(date: Date): string;
function formatDate(date: string): Date;
function formatDate(date: Date | string): string | Date {
if (date instanceof Date) return date.toISOString();
return new Date(date);
}
const str = formatDate(new Date()); // type: string ✅
const dt = formatDate("2026-01-01"); // type: Date ✅
7. The Builder Pattern with Fluent Types
// Type-safe query builder
class QueryBuilder<T extends object, Selected extends keyof T = never> {
select<K extends keyof T>(...fields: K[]): QueryBuilder<T, Selected | K> {
// Returns new builder with updated type
return this as any;
}
build(): Pick<T, Selected> {
// Only returns the selected fields
return {} as Pick<T, Selected>;
}
}
type User = { id: string; name: string; email: string; password: string };
const result = new QueryBuilder<User>()
.select("id", "name", "email")
.build();
// type: { id: string; name: string; email: string } — password excluded ✅
The Pattern Decision Matrix
| Problem | Pattern | Benefit |
|---|---|---|
| Multiple state booleans | Discriminated Union | Exhaustive checking |
| Interchangeable IDs | Branded Types | Compile-time ID safety |
| String validation | Template Literals | No invalid string values |
| Conditional behavior | Conditional Types | Type-level logic |
| Config validation | satisfies | Validate + preserve specificity |
| Polymorphic functions | Overloads | Precise return types |
| Step-by-step construction | Builder Pattern | Track state in types |
Important
Don't introduce advanced types for their own sake. The bar is: does this prevent a real bug? If the answer is yes, use it. If it's just type-system cleverness that adds cognitive overhead without a real safety benefit, skip it.
Conclusion
TypeScript's type system is a programming language in itself. You can express invariants, encode business rules, and make impossible states literally impossible to represent. The developers who understand this are the ones who write code that almost never fails in production — not because they're more careful, but because the compiler is their co-pilot.
Learn these 7 patterns. Apply them where they make bugs impossible. Your future self (and your teammates) will thank you.
Arjun Singh
Technical Architect
