Skip to main content
Frontend

TypeScript Best Practices

Advanced TypeScript patterns for building type-safe, maintainable applications at scale.

Mar 5, 2024·12 min read
TypeScriptBest PracticesCode Quality

TypeScript's type system is far more expressive than most engineers use day-to-day. After years of working across NestJS backends and Next.js/Angular frontends, these are the patterns that have eliminated entire categories of bugs in my codebases.

Discriminated Unions Over Optional Fields

The most common TypeScript anti-pattern I see is using optional fields to represent state. This forces every consumer to check for undefined and invites runtime errors when state combinations are logically impossible.

typescript
// ❌ Optional fields — invalid states are representable
interface FetchState<T> {
  data?: T;
  error?: Error;
  loading: boolean;
}

// ✅ Discriminated union — only valid states exist
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

// TypeScript now narrows correctly inside switch/if
function render<T>(state: FetchState<T>) {
  if (state.status === 'success') {
    return state.data; // T — no undefined check needed
  }
}

Branded Types for Domain Safety

TypeScript's structural type system means two number aliases are interchangeable by default. Branded types add a nominal flavour, preventing you from accidentally passing a userId where a postId is expected.

typescript
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

const toUserId = (id: string): UserId => id as UserId;
const toPostId = (id: string): PostId => id as PostId;

function getPost(id: PostId) { /* ... */ }

const userId = toUserId('abc');
getPost(userId); // ✅ Type error — cannot use UserId as PostId

The satisfies Operator

Introduced in TypeScript 4.9, satisfies validates a value against a type while preserving the most specific type. This is perfect for config objects and lookup maps.

typescript
const ROUTES = {
  home: '/',
  blog: '/blog',
  about: '/about',
} satisfies Record<string, string>;

// ROUTES.home is typed as '/' not string — autocomplete works
// And TS still enforces that all values are strings

Template Literal Types for API Contracts

Template literal types let you encode string constraints that previously lived only in runtime validation. They're particularly powerful for event systems and API route definitions.

typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2';
type ApiRoute = `/api/${ApiVersion}/${string}`;

type EventName<T extends string> = `on${Capitalize<T>}`;
type ButtonEvents = EventName<'click' | 'hover' | 'focus'>;
// = 'onClick' | 'onHover' | 'onFocus'

Const Assertions and Enum Alternatives

Avoid TypeScript enums — they generate runtime JavaScript and have several surprising edge cases. Use const objects with as const instead, then derive the union type from the values.

typescript
// ❌ Enum — emits JS, no tree-shaking, numeric pitfalls
enum Status { Active, Inactive }

// ✅ Const object — zero runtime cost, full type safety
const STATUS = {
  ACTIVE: 'active',
  INACTIVE: 'inactive',
} as const;

type Status = typeof STATUS[keyof typeof STATUS];
// = 'active' | 'inactive'

Infer in Conditional Types

The infer keyword lets you extract type information from within a conditional type. It's the foundation of utility types like ReturnType, Parameters, and Awaited.

typescript
// Extract the resolved type of any Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;

// Extract the element type of any array
type ElementType<T> = T extends (infer E)[] ? E : never;

// Extract a specific HTTP handler's response type
type HandlerResponse<T extends (...args: unknown[]) => unknown> =
  Awaited<ReturnType<T>>;

Written by

Md. Saniuzzaman Robin

Full-Stack Software Engineer

More Articles →