Back to Blog
tips7 min read

10 TypeScript Patterns for Cleaner Code

A practical collection of 10 TypeScript patterns — from discriminated unions and template literal types to the satisfies operator — that make your codebase more type-safe and maintainable.

V
By Ventra Rocket
·Published on 15 February 2026
#TypeScript#Clean Code#Patterns#Best Practices

TypeScript is more than JavaScript with type annotations. Used correctly, it catches bugs at compile time, improves developer experience, and makes codebases dramatically easier to refactor. These are 10 patterns the Ventra Rocket team uses daily across all production projects.

1. Discriminated Unions Instead of Boolean Flags

Multiple boolean flags can produce invalid states that are impossible to represent correctly:

// Bad: allows impossible states like isLoading=true AND error="..."
interface DataState {
  isLoading: boolean;
  data?: User[];
  error?: string;
}

// Good: discriminated union guarantees valid state at all times
type DataState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function renderState(state: DataState) {
  switch (state.status) {
    case 'loading': return <Spinner />;
    case 'success': return <UserList users={state.data} />; // data guaranteed present
    case 'error':   return <ErrorView message={state.error} />;
    default:        return null;
  }
}

2. Template Literal Types for Type-Safe Strings

type EventName = `on${Capitalize<string>}`;
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type ApiRoute = `/api/v${number}/${string}`;

function request(method: HttpMethod, url: ApiRoute) { /* ... */ }
request('GET', '/api/v1/users');   // OK
request('GET', '/v1/users');       // Error: must start with /api/v

3. The satisfies Operator (TS 4.9+)

// satisfies preserves literal types while still checking against the interface
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3,
} satisfies Record<string, string | number>;

const palette = {
  primary: '#06d6a0',
  secondary: '#8b5cf6',
} satisfies Record<string, `#${string}`>;
// Compile error if any value is not a valid hex colour string

4. Infer in Conditional Types

// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;

// Extract route params from a path string literal
type RouteParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof RouteParams<Rest>]: string }
    : T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type UserRoute = RouteParams<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }

5. Builder Pattern with Method Chaining

class QueryBuilder<T> {
  private filters: Partial<T>[] = [];
  private sortField?: keyof T;
  private limitValue = 50;

  where(filter: Partial<T>): this {
    this.filters.push(filter);
    return this;
  }

  orderBy(field: keyof T): this {
    this.sortField = field;
    return this;
  }

  limit(n: number): this {
    this.limitValue = n;
    return this;
  }

  build(): string {
    return JSON.stringify({
      filters: this.filters,
      sort: this.sortField,
      limit: this.limitValue,
    });
  }
}

const query = new QueryBuilder<User>()
  .where({ role: 'admin' })
  .orderBy('createdAt')
  .limit(20)
  .build();

6. Readonly and Deep Immutability

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

type ImmutableConfig = DeepReadonly<{
  database: { host: string; port: number };
  features: string[];
}>;
// Every nested property is readonly — mutation causes a compile error

7. Function Overloads for Clear APIs

function parseInput(input: string): number;
function parseInput(input: number): string;
function parseInput(input: string | number): string | number {
  if (typeof input === 'string') return parseInt(input, 10);
  return input.toString();
}

const num = parseInput('42');  // TypeScript knows return type is number
const str = parseInput(42);    // TypeScript knows return type is string

8. Mapped Types for Object Transformation

// Make all values into arrays
type Arrayify<T> = { [K in keyof T]: T[K][] };

// Extract only method keys
type MethodKeys<T> = {
  [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never
}[keyof T];

// Generate event map from state type
type StateEventMap<T> = {
  [K in keyof T as `${string & K}Changed`]: (newValue: T[K]) => void;
};

9. Branded Types for Domain Primitives

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId    = Brand<string, 'UserId'>;
type OrderId   = Brand<string, 'OrderId'>;
type ProductId = Brand<string, 'ProductId'>;

function createUserId(id: string): UserId { return id as UserId; }

function getUser(id: UserId)  { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }

const userId = createUserId('user-123');
getUser(userId);   // OK
getOrder(userId);  // Compile error: UserId is not assignable to OrderId

10. as const for Precise Literal Types

const ROUTES = {
  home:    '/',
  about:   '/about',
  blog:    '/blog',
  contact: '/contact',
} as const;

type Route = typeof ROUTES[keyof typeof ROUTES];
// '/' | '/about' | '/blog' | '/contact'

// Precise tuple types
function createPoint(x: number, y: number) {
  return [x, y] as const; // readonly [number, number] — not number[]
}

Conclusion

TypeScript is most powerful when you let the compiler do the work. Discriminated unions eliminate null checks, branded types prevent logic bugs, and template literal types move runtime errors to compile time. Investing time in these patterns produces codebases with fewer bugs and far easier refactoring paths.

Related Articles

10 TypeScript Patterns for Cleaner Code | Ventra Rocket