Skip to content

TypeScript Clean Code — 15 Patterns for Better Software

Fifteen TypeScript patterns that cut bugs, improve readability, and scale to large codebases. With real before-and-after examples from production code.

· · 8 min read
Six categories of TypeScript clean code patterns: type precision, structural clarity, compiler as designer

Quick Take

Most clean code advice was written for dynamically typed languages. TypeScript changes the rules. The type system itself can enforce correctness that would otherwise require code reviews, documentation, and hope. These fifteen patterns use TypeScript's features as design tools, not just annotations.

Most "clean code" books were written before TypeScript existed. The advice is sound, but it's missing something: when you have static types, you can make wrong code impossible to write, not just wrong code that looks wrong, helping you build better systems, covering software integration.

I've reviewed TypeScript codebases for two years, projects ranging from 5,000 to 500,000 lines. The same fifteen patterns appear in every one that's easy to change. Here they are.

TL;DR:

  • Discriminated unions and unknown make invalid states and type-unsafe boundaries impossible
  • Short functions with explicit return types force clarity at the declaration site
  • Mapped types, readonly, and const assertions use the compiler as a design enforcer
  • All fifteen patterns have zero runtime cost, they compile away completely

When to use this vs the anti-patterns guide: This post is a design playbook, 15 patterns to adopt when starting fresh or designing new modules. If you're auditing an existing codebase looking for what to remove, the TypeScript code smells is the companion code-review checklist.

Five Type Precision Patterns

1. unknown Instead of any at Boundaries

any is a type escape hatch. unknown is a type contract: "I don't know what this is, caller must narrow before using it." Use unknown everywhere you receive external data.

// Bad: any silently turns off type checking
async function fetchUser(id: string): Promise<any> { ... }

// Good: unknown forces the caller to validate
async function fetchUser(id: string): Promise<unknown> { ... }
// Now callers must parse/narrow before accessing properties

Pair unknown with Zod or a type guard at the entry point. The rest of your codebase stays typed.

2. Discriminated Unions Over Boolean Flags

A function that returns { loading: boolean; data: User | null; error: string | null } has 8 theoretical states. Only 3 are valid. TypeScript can't know that. Replace it with a discriminated union:

// Three booleans hiding one reality
type QueryState = { loading: boolean; data: User | null; error: string | null };

// The actual three states, each with only the fields that make sense
type QueryState =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string };

Switch on status and TypeScript knows exactly which fields are available in each branch. Invalid states become impossible to represent.

3. const Assertions for Configuration

Configuration objects have literal values, not general types. const assertions tell TypeScript to infer the narrowest possible type:

// TypeScript infers: { method: string, timeout: number }
const config = { method: 'GET', timeout: 5000 };

// TypeScript infers: { readonly method: 'GET', readonly timeout: 5000 }
const config = { method: 'GET', timeout: 5000 } as const;

The second version is the exact literal type. config.method is the literal 'GET', not the broad string. Functions that accept 'GET' | 'POST' | 'PUT' | 'DELETE' will accept it without casting.

4. Mapped Types for Staying in Sync

Don't derive one type from another manually. Mapped types do it automatically, and they update when the source changes.

type FormFields = { email: string; password: string; name: string };

// These update automatically when FormFields changes:
type FormErrors = Partial<Record<keyof FormFields, string>>;
type FormTouched = Record<keyof FormFields, boolean>;

Add a field to FormFields and both derived types gain the new field. Remove a field and TypeScript flags anywhere the removed field is still referenced.

5. Readonly for Shared Data

Mutation is the silent killer of TypeScript codebases. readonly arrays and properties turn accidental mutation into a compile error:

function processOrders(orders: readonly Order[]): OrderSummary {
  // orders.push(...), TypeScript error; cannot mutate parameter
  return orders.reduce(/* ... */);
}

A function that accepts readonly T[] can take both mutable and immutable arrays. Start readonly; relax only when you have a reason.

A 3D illustration of layered code editor windows connected above a laptop
Photo by Philip Oroni on Unsplash

Five Function Design Patterns

6. Explicit Return Types on Public Functions

TypeScript infers return types, but inference is documentation that can drift. An explicit return type is a contract: the function must return exactly that type or TypeScript tells you immediately.

// Inferred, return type can change silently
function getUser(id: string) {
  return users.find(u => u.id === id);
}

// Explicit, compiler enforces the contract
function getUser(id: string): User | undefined {
  return users.find(u => u.id === id);
}

Every public function should have an explicit return type. Private implementation details can rely on inference.

7. Guard Clauses Over Nested Conditionals

Deeply nested conditionals are hard to read and hard to type correctly. Return early for each failure condition:

// Nested: reader has to track all three conditions simultaneously
function processPayment(order: Order) {
  if (order.status === 'pending') {
    if (order.items.length > 0) {
      if (order.total > 0) { /* actual logic */ }
    }
  }
}

// Guard clauses: each failure is obvious and isolated
function processPayment(order: Order) {
  if (order.status !== 'pending') return;
  if (order.items.length === 0) return;
  if (order.total <= 0) return;
  // actual logic, TypeScript knows order is fully valid here
}

8. Avoid Boolean Trap Parameters

A function called render(true) is unreadable at the call site. What does true mean? Use an options object:

// Trap: caller has no idea what false means
function fetchUsers(includeDeleted: boolean): User[]

// Clear: the intent is visible at every call site
function fetchUsers(options: { includeDeleted: boolean }): User[]
fetchUsers({ includeDeleted: false })

Boolean traps compound: render(true, false, true) is completely opaque. Options objects scale.

9. Named Type Parameters in Generics

Single-letter type parameters like T and U are fine for short utilities. Longer functions benefit from descriptive names:

// What is T? What is U?
function merge<T, U>(source: T, override: U): T & U

// The names communicate the relationship
function merge<TBase, TOverride>(base: TBase, override: TOverride): TBase & TOverride

10. Result Types Over Throw

Exceptions break the type system, you can't see them in a function's signature. Use a Result type for expected failure modes:

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseConfig(input: string): Result<Config> {
  // caller sees from the signature that this can fail
}

Reserve throw for truly unexpected errors. Callers of parseConfig know they have to handle both branches.

A code editor showing a project file tree beside TypeScript React source code
Photo by Juanjo Jaramillo on Unsplash

Five Architecture Patterns

11. Colocate Types With Their Consumers

A types.ts file that grows without bound is a symptom, not a solution. Define types next to the code that uses them. A component's props type lives in the component file. A service's request and response types live in the service file.

12. Interface Segregation

Don't force callers to depend on methods they don't use. Split large interfaces into smaller, focused ones:

// One big interface forces all implementors to provide everything
interface Repository { findById, findAll, create, update, delete, paginate, search }

// Small interfaces; implementors only provide what callers need
interface Readable<T> { findById(id: string): Promise<T | null> }
interface Writable<T> { create(data: Omit<T, 'id'>): Promise<T> }

13. Template Literal Types for String Patterns

TypeScript 4.1+ template literal types let you define string patterns as types:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiPath = `/api/${string}`;
type Route = `${HttpMethod} ${ApiPath}`;

function register(route: Route, handler: Handler): void {}
register('GET /api/users', handler);   // OK
register('PATCH /api/users', handler); // TypeScript error

14. Exhaustiveness Checking in Switch Statements

When you switch on a discriminated union, TypeScript can enforce that you handle every case. Use the never type to make missed cases a compile error:

function getLabel(status: OrderStatus): string {
  switch (status) {
    case 'pending': return 'Pending';
    case 'shipped': return 'Shipped';
    case 'delivered': return 'Delivered';
    default:
      const _exhaustive: never = status; // compile error if a case is missing
      return _exhaustive;
  }
}

Add a new variant to OrderStatus and this function fails to compile until you handle it.

15. Document the Why, Not the What

Type annotations document what. Comments should document why: the business rule that isn't obvious, the constraint from an external system, the workaround for a specific bug.

// Bad: the code already says this
// Returns the first active user
function getFirstActiveUser(users: User[]): User | undefined {
  return users.find(u => u.status === 'active');
}

// Good: explains a non-obvious constraint
// Billing requires exactly one active user per account (legacy API constraint).
// Returns undefined if the account is in an invalid state.
function getPrimaryBillingUser(users: User[]): User | undefined {
  return users.find(u => u.status === 'active');
}

The function names and types already tell you the what. Comments earn their weight when they explain a constraint a future reader couldn't derive from the code alone.

These patterns aren't independent rules. They compound: strict TypeScript config enforces readonly and catches unsafe patterns automatically. The vibe coding quality framework runs these checks in CI so every AI-generated pull request gets the same scrutiny as handwritten code. For a deeper look at the structural principles behind patterns like Interface Segregation (pattern 12), the SOLID principles in TypeScript guide covers all five principles with examples from real-world auth services and HTTP clients.

Frequently Asked Questions

Are clean code patterns different in TypeScript than in JavaScript?
Yes, significantly. TypeScript's type system allows you to encode constraints that would require runtime checks or documentation in JavaScript. Discriminated unions make invalid states unrepresentable. Mapped types keep derived structures in sync automatically. Readonly prevents mutation by contract. The language itself becomes a design tool, the patterns shift from 'how to write defensive code' to 'how to use types to make bad states impossible'.
Should I use type or interface for defining data shapes?
Prefer interfaces for object shapes that may be extended or implemented by classes. Use type aliases for everything else: unions, intersections, mapped types, conditional types, and tuples. The meaningful difference is that interfaces can be reopened (declaration merging) while type aliases cannot. For most application code, domain models, API responses, component props, the choice rarely matters. Pick one and be consistent.
What is a discriminated union and when should I use one?
A discriminated union is a type with a shared literal property (the discriminant) that tells TypeScript which variant you're looking at. Use them whenever a value can be in one of several mutually exclusive states: loading/success/error, different event types, different entity variants. The discriminant property lets TypeScript narrow the type in switch/if blocks, giving you exhaustiveness checking and precise typing in each branch.
Do these TypeScript patterns have any runtime performance cost?
No. TypeScript types are erased at compile time, they produce zero JavaScript output. Type assertions, discriminated unions, mapped types, readonly modifiers, const assertions, none of these add any bytes or operations to the compiled output. The performance wins from clean TypeScript come from catching bugs before they reach production, not from the types themselves.