TypeScript Best Practices
Advanced TypeScript patterns for building type-safe, maintainable applications at scale.
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.
// ❌ 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.
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 PostIdThe 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.
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 stringsTemplate 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.
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.
// ❌ 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.
// 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