Skip to content

15 Clean Code Patterns for TypeScript Types and Functions

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

· · 11 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.

TypeScript clean code refers to a set of design patterns that use the compiler itself, not just naming conventions or code review, to make incorrect states difficult or impossible to represent in the first place. 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.

Review enough TypeScript codebases, from 5,000-line side projects to 500,000-line platforms, and a pattern emerges: the same fifteen habits appear in every one that's easy to change. Here they are.

Quick take: These fifteen patterns split into three groups: five type-precision patterns (unknown over any, discriminated unions, const assertions), five function-design patterns (explicit return types, guard clauses, Result types), and five architecture patterns (interface segregation, exhaustiveness checking). All fifteen compile away to zero runtime cost.

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.

What Are the Five Type Precision Patterns?

Type precision patterns share one goal: shrink the set of values a type can hold down to exactly the ones that are valid, so the compiler catches a mismatch instead of a user. According to the TypeScript Handbook's narrowing guide, unknown and discriminated unions are the two mechanisms the language itself recommends for this, and in practice they cover the large majority of boundary-safety problems a typical service layer runs into. I've measured this directly on a mid-size API project: swapping fifteen any return types for unknown plus a Zod parse at the boundary surfaced four real bugs that had been silently passing malformed data three layers deep, none of which unit tests had caught because the tests themselves used the same loosely typed fixtures as production.

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

What Are the Five Function Design Patterns?

Function design patterns focus on the call site: what a reader can tell about a function's behavior without opening its body. An explicit return type, a guard clause, an options object instead of three booleans, each one moves information from "you have to read the implementation" to "you can see it in the signature." That distinction matters more as a codebase grows past a handful of contributors, since nobody reads every implementation before calling a function, they read the signature and trust it. Two patterns pull the most weight here: explicit return types, which stop inference from silently drifting as a function evolves, and Result types, which make error handling visible in the type signature instead of hidden behind an untyped throw.

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

What Are the Five Architecture Patterns?

Architecture patterns operate at the module and package boundary rather than inside a single function, and they're the ones most likely to get skipped under deadline pressure because their payoff shows up months later, not in the next code review. Interface segregation and exhaustiveness checking are the two with the sharpest cost curve if skipped: a bloated repository interface forces every mock in your test suite to implement methods it never calls, and a switch statement without a never exhaustiveness check silently drops new enum variants instead of failing the build. Per the TypeScript Handbook's guidance on narrowing and exhaustiveness, the never type assigned in a default branch is the idiomatic way to force this, and it costs one line per switch statement.

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.

All 15 Patterns at a Glance

CategoryPatterns
Type precision (1-5)unknown over any, discriminated unions, const assertions, mapped types, readonly
Function design (6-10)Explicit return types, guard clauses, no boolean traps, named type parameters, Result types over throw
Architecture (11-15)Colocate types, interface segregation, template literal types, exhaustiveness checking, document the why

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.