All Articles
TypeScript

TypeScript Wizardry: 7 Patterns That Will Make You Irreplaceable

Most developers use TypeScript as 'JavaScript with types'. These 7 patterns show you how to use it as a compile-time safety net that makes entire classes of bugs impossible.

March 8, 20266 min read
TypeScript Wizardry: 7 Patterns That Will Make You Irreplaceable

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:

TypeScript
// ❌ 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.

TypeScript
// ✅ 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
// ❌ 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

TypeScript
// 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

TypeScript
// 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

TypeScript
// ❌ 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

TypeScript
// ❌ 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

TypeScript
// 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

ProblemPatternBenefit
Multiple state booleansDiscriminated UnionExhaustive checking
Interchangeable IDsBranded TypesCompile-time ID safety
String validationTemplate LiteralsNo invalid string values
Conditional behaviorConditional TypesType-level logic
Config validationsatisfiesValidate + preserve specificity
Polymorphic functionsOverloadsPrecise return types
Step-by-step constructionBuilder PatternTrack 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

Arjun Singh

Technical Architect

Driven by Quality. Built for Performance.

Need help?

Chat with me