Skip to content

TypeScript – Best Practices

This page covers the day-to-day practices that separate robust TypeScript from code that has types as an afterthought. These are grounded in how the type system actually works and how TypeScript is used in production codebases in 2026.

Type Inference — Let TypeScript Work

Annotate where it matters. Let inference handle the rest:

// ✅ Let TypeScript infer obvious types
const orderId   = 'ord_123';              // inferred: string
const count     = 0;                      // inferred: number
const orders    = new Map<string, Order>(); // inferred: Map<string, Order>
const isLoading = false;                  // inferred: boolean

// ✅ Always annotate function parameters and return types
function calculateDiscount(subtotal: number, rate: number): number {
  return subtotal * (1 - rate);
}

// ✅ Always annotate class members
class OrderService {
  private readonly repository: OrderRepository;
  private readonly logger: Logger;

  constructor(repository: OrderRepository, logger: Logger) {
    this.repository = repository;
    this.logger = logger;
  }
}

// ❌ Redundant annotations — TypeScript already knows these
const name: string  = 'Alice';
const total: number = price + tax;

function add(a: number, b: number): number {
  return a + b;  // return type is obvious — no need to annotate
}

When to Always Annotate

Situation Why
All function parameters Inference cannot help — the caller needs the contract
Exported function return types Consumers depend on them — be explicit
Class properties and fields Avoids accidental widening
Variables initialised to null / undefined Inference produces null — not useful
Results of external data (API, DB, JSON) TypeScript cannot verify external shapes

Immutability

Immutable data eliminates an entire class of bugs where functions unexpectedly mutate their inputs:

// ✅ readonly on function parameters
function calculateTotal(items: readonly OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// ✅ readonly on interface properties
interface OrderSummary {
  readonly id: string;
  readonly total: number;
  readonly status: OrderStatus;
  readonly createdAt: Date;
}

// ✅ ReadonlyArray for arrays that must not be modified
function processOrders(orders: ReadonlyArray<Order>): ProcessedOrder[] {
  return orders.map(transform);
}

// ✅ as const for deeply immutable configuration
const CONFIG = {
  api: { baseUrl: 'https://api.cygnusdynamics.com', version: 'v2', timeout: 5000 },
  pagination: { defaultPageSize: 20, maxPageSize: 100 },
} as const;

// ✅ Return new objects — never mutate parameters
function applyDiscount(order: Order, discountRate: number): Order {
  return { ...order, total: order.total * (1 - discountRate), discountApplied: true };
}

// ❌ Mutating the input
function applyDiscount(order: Order, rate: number): Order {
  order.total = order.total * (1 - rate);  // unexpected side effect
  return order;
}

Null and Undefined — Handle Explicitly

With strictNullChecks, null and undefined must be handled. This is a feature:

// ✅ Optional chaining
const city         = user?.address?.city;
const firstItemId  = cart?.items?.[0]?.id;

// ✅ Nullish coalescing — default for null/undefined only
const displayName = user?.name ?? 'Anonymous';
const pageSize    = params.pageSize ?? DEFAULT_PAGE_SIZE;

// ✅ Nullish assignment
config.timeout ??= 5000;

// ✅ Explicit null return pattern — signal absence without throwing
async function findUser(id: string): Promise<User | null> {
  return db.users.findUnique({ where: { id } });
}
const user = await findUser(userId);
if (!user) return res.status(404).json({ error: 'User not found' });

// ❌ || for defaults — triggers on 0, '', false — use ?? instead
const pageSize = params.pageSize || 20;  // bug: pageSize of 0 becomes 20

Async/Await with Types

Always type the resolved value of a Promise:

// ✅ Typed async function
async function fetchOrder(id: string): Promise<Order> {
  const response = await fetch(`/api/v1/orders/${id}`);
  if (!response.ok) throw new OrderNotFoundError(id);
  return response.json() as Promise<Order>;
}

// ✅ Generic fetch wrapper
async function apiGet<TData>(url: string): Promise<TData> {
  const response = await fetch(url);
  if (!response.ok) throw new ApiError(response.status, await response.text());
  return response.json() as TData;
}

// ✅ Typing Promise.all — TypeScript correctly infers the tuple
const [user, orders, preferences] = await Promise.all([
  fetchUser(userId),        // Promise<User>
  fetchOrders(userId),      // Promise<Order[]>
  fetchPreferences(userId), // Promise<UserPreferences>
]);
// type: [User, Order[], UserPreferences]

// ✅ Handle unknown errors in catch — err is unknown in strict mode
async function processOrder(id: string): Promise<void> {
  try {
    const order = await fetchOrder(id);
    await fulfil(order);
  } catch (err) {
    if (err instanceof OrderNotFoundError) {
      logger.warn('Order not found', { id });
    } else if (err instanceof Error) {
      logger.error('Order processing failed', { id, message: err.message });
      throw err;
    } else {
      throw new Error('Unknown error processing order');
    }
  }
}

Error Handling with Types

// ✅ Type-safe error hierarchy
class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly httpStatus: number = 500,
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

class ValidationError extends AppError {
  constructor(
    message: string,
    public readonly details: Record<string, string[]>,
  ) {
    super(message, 'VALIDATION_ERROR', 400);
  }
}

class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
  }
}

// ✅ Narrow errors in catch blocks
try {
  await processPayment(order);
} catch (err: unknown) {
  if (err instanceof PaymentDeclinedError) {
    return { success: false, code: 'PAYMENT_DECLINED', message: err.message };
  }
  if (err instanceof NetworkError) {
    return { success: false, code: 'NETWORK_ERROR', message: 'Please try again' };
  }
  if (err instanceof Error) {
    logger.error('Unexpected error', { message: err.message });
    throw err;
  }
  throw new Error('Unknown error during payment');
}

Branded Types in Practice

TypeScript's structural type system means any two string values are interchangeable — even an OrderId and a UserId. Branded types create nominally distinct types from the same primitive, making transposition errors a compile error rather than a runtime bug.

// ✅ Brand pattern
type Brand<T, TBrand extends string> = T & { readonly _brand: TBrand };

type OrderId      = Brand<string, 'OrderId'>;
type UserId       = Brand<string, 'UserId'>;
type Cents        = Brand<number, 'Cents'>;
type EmailAddress = Brand<string, 'EmailAddress'>;

// ✅ Constructor/validator functions that produce the branded type
function toOrderId(raw: string): OrderId {
  if (!raw.startsWith('ord_')) throw new Error(`Invalid OrderId: "${raw}"`);
  return raw as OrderId;
}

function toCents(amount: number): Cents {
  if (!Number.isInteger(amount) || amount < 0) {
    throw new RangeError(`Cents must be a non-negative integer, got: ${amount}`);
  }
  return amount as Cents;
}

// ✅ Functions accept only the branded type — transposition is now a compile error
async function chargeOrder(orderId: OrderId, userId: UserId, amount: Cents): Promise<void> {
  // ...
}

const orderId = toOrderId('ord_abc');
const userId  = toUserId('usr_xyz');
const amount  = toCents(9999);

chargeOrder(orderId, userId, amount);  // ✅
chargeOrder(userId, orderId, amount);  // ❌ Compile error — argument types transposed
chargeOrder(orderId, userId, 9999);    // ❌ Compile error — plain number, not Cents

Zod + Branded Types

Combine Zod validation with branded types so that every value that crosses the external boundary is both runtime-validated and compile-time typed:

import { z } from 'zod';

// ✅ .brand() creates a Zod schema that produces a branded type
const OrderIdSchema = z.string()
  .min(1)
  .startsWith('ord_')
  .brand<'OrderId'>();

const UserIdSchema = z.string()
  .min(1)
  .startsWith('usr_')
  .brand<'UserId'>();

const CentsSchema = z.number()
  .int()
  .nonnegative()
  .brand<'Cents'>();

type OrderId = z.infer<typeof OrderIdSchema>;  // Brand<string, 'OrderId'>
type Cents   = z.infer<typeof CentsSchema>;    // Brand<number, 'Cents'>

// In a route handler — parse validates at runtime and brands at compile time
async function getOrder(req: Request, res: Response): Promise<void> {
  const orderId = OrderIdSchema.parse(req.params.id);  // OrderId, not plain string
  const order = await orderService.findById(orderId);
  res.json(order);
}

Module Organisation

Type-Only Imports

Use import type for type-only imports. They are erased from the compiled output entirely and prevent accidental runtime dependencies:

// ✅ import type — zero runtime cost
import type { Order, OrderStatus } from './order.types.js';
import type { Repository } from '../lib/repository.js';

// ✅ Inline type imports (enforced by consistent-type-imports ESLint rule)
import { type Order, OrderService } from './orders.js';

// ❌ Regular import for type-only values
import { Order } from './order.types.js';  // Order is a type — use import type

Barrel Files (Index Exports)

Use index.ts barrels to define the public API of a feature. Consumers import from the barrel, not internal files:

// features/orders/index.ts — public API
export { OrderService }                               from './order.service.js';
export { OrderRepository }                            from './order.repository.js';
export type { Order, CreateOrderRequest }              from './order.types.js';
export { OrderNotFoundError, OrderAlreadyCancelledError } from './order.errors.js';

// Consumer
import { OrderService, type Order } from '@/features/orders';  // ✅

// NOT from internal paths
import { OrderService } from '@/features/orders/order.service';  // ❌

Runtime Validation with Zod

TypeScript types are compile-time only. For data from external sources — HTTP requests, API responses, environment variables — use Zod to validate and type simultaneously:

import { z } from 'zod';

// ✅ Define schema — derive TypeScript type from it — single source of truth
const CreateOrderSchema = z.object({
  userId:   z.string().uuid(),
  currency: z.enum(['USD', 'EUR', 'GBP']),
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity:  z.number().int().positive().max(100),
  })).min(1, 'Order must have at least one item'),
  couponCode: z.string().max(50).optional(),
});

type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;

z.input<T> vs z.output<T> — Input and Output Types

When a Zod schema uses transforms (.transform(), .default(), .coerce), the input type (what you pass to .parse()) differs from the output type (what .parse() returns). Use z.input<T> and z.output<T> to access both:

const OrderItemSchema = z.object({
  productId: z.string().uuid(),
  quantity:  z.number().int().positive(),
  addedAt:   z.string().datetime().transform(s => new Date(s)),  // string → Date
});

// z.infer = z.output — the type after transformation
type OrderItem = z.infer<typeof OrderItemSchema>;
// { productId: string; quantity: number; addedAt: Date }

// z.input — the type before transformation (what comes from the API/form)
type OrderItemInput = z.input<typeof OrderItemSchema>;
// { productId: string; quantity: number; addedAt: string }  — string, not Date

// ✅ Use z.input when typing the raw data before parsing
function parseOrderItem(raw: OrderItemInput): OrderItem {
  return OrderItemSchema.parse(raw);
}

// ✅ Use z.output (= z.infer) when typing the validated, transformed result
function displayItem(item: OrderItem): string {
  return `${item.productId} x${item.quantity} (added ${item.addedAt.toISOString()})`;
}

Zod Schema Composition

// ✅ Base schema — reused across create/update
const OrderBaseSchema = z.object({
  currency: z.enum(['USD', 'EUR', 'GBP']),
  items: z.array(z.object({
    productId: z.string().uuid(),
    quantity:  z.number().int().positive().max(100),
  })).min(1),
});

// Extend for create — add userId
const CreateOrderSchema = OrderBaseSchema.extend({
  userId: z.string().uuid(),
});

// Partial for update — all fields optional, id required separately
const UpdateOrderSchema = OrderBaseSchema.partial().extend({
  status: z.enum(['confirmed', 'cancelled']).optional(),
});

type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
type UpdateOrderRequest = z.infer<typeof UpdateOrderSchema>;

Zod for Environment Variables

Validate environment variables at application startup. Fail immediately with a clear error rather than later at the point of use:

// src/config/env.ts
import { z } from 'zod';

const EnvSchema = z.object({
  NODE_ENV:           z.enum(['development', 'test', 'production']),
  PORT:               z.coerce.number().int().positive().default(3000),
  DATABASE_URL:       z.string().url(),
  STRIPE_SECRET_KEY:  z.string().startsWith('sk_'),
  JWT_SECRET:         z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  REDIS_URL:          z.string().url().optional(),
});

// Fails at startup with a clear, field-level error message
export const env = EnvSchema.parse(process.env);
// type: { NODE_ENV: 'development' | 'test' | 'production'; PORT: number; ... }

// Usage — fully typed, no string casting
import { env } from '@/config/env.js';
const stripe = new Stripe(env.STRIPE_SECRET_KEY);

using — Resource Management (TypeScript 5.2+)

The using keyword provides deterministic cleanup of resources that implement Symbol.dispose. The resource is automatically disposed when the block exits — even if an exception is thrown:

// ✅ Implement Symbol.dispose on any resource that needs cleanup
class DatabaseTransaction {
  private committed = false;

  constructor(private readonly db: Database) {}

  async commit(): Promise<void> {
    await this.db.commit();
    this.committed = true;
  }

  [Symbol.dispose](): void {
    if (!this.committed) {
      // Automatically rolled back when the using block exits
      void this.db.rollback();
    }
  }
}

// ✅ using — disposes automatically at end of block
async function transferFunds(fromId: string, toId: string, amount: Cents): Promise<void> {
  using transaction = new DatabaseTransaction(db);

  const from = await db.accounts.findById(fromId);
  const to   = await db.accounts.findById(toId);

  await db.accounts.update(fromId, { balance: from.balance - amount });
  await db.accounts.update(toId,   { balance: to.balance + amount });

  await transaction.commit();
  // If an exception was thrown before commit(), transaction[Symbol.dispose]()
  // runs automatically and rollback() is called
}

// ✅ await using — for async cleanup (Symbol.asyncDispose)
class RedisLock {
  constructor(private readonly key: string) {}

  async acquire(): Promise<void> {
    await redis.set(this.key, '1', 'EX', 30);
  }

  async [Symbol.asyncDispose](): Promise<void> {
    await redis.del(this.key);
  }
}

async function processOnce(orderId: OrderId): Promise<void> {
  await using lock = new RedisLock(`lock:order:${orderId}`);
  await lock.acquire();
  await processOrder(orderId);
  // lock is released automatically via Symbol.asyncDispose
}

Documentation with TSDoc

All exported functions, classes, and complex types must have a TSDoc comment:

/**
 * Calculates the discounted total for an order, applying tax after discount.
 *
 * Discounts above 50% are capped per commercial policy CP-2024-003.
 *
 * @param order - The order to price. Must have at least one item.
 * @param discountRate - Fractional discount (0.0 to 0.5 inclusive).
 * @returns The final payable amount in the order's currency, rounded to 2dp.
 * @throws {ValidationError} If `discountRate` is outside the range [0, 0.5].
 *
 * @example
 * ```typescript
 * const total = calculateDiscountedTotal(order, 0.10);
 * console.log(total); // 89.99
 * ```
 */
export function calculateDiscountedTotal(order: Order, discountRate: number): number {
  if (discountRate < 0 || discountRate > 0.5) {
    throw new ValidationError('Discount rate must be between 0 and 0.5', {
      discountRate: [`Got ${discountRate}`],
    });
  }
  const subtotal = order.items.reduce((s, i) => s + i.price * i.quantity, 0);
  return Math.round(subtotal * (1 - discountRate) * 100) / 100;
}

Testing with TypeScript

Use Vitest for all TypeScript projects. TypeScript's type system makes test data correct at compile time:

// order-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { MockedObject } from 'vitest';
import { OrderService } from './order-service.js';
import type { OrderRepository } from './order.types.js';

// ✅ Typed mock — TypeScript ensures mock matches the interface shape
const mockRepository = {
  findById: vi.fn<[string], Promise<Order | null>>(),
  save:     vi.fn<[Order], Promise<Order>>(),
  delete:   vi.fn<[string], Promise<void>>(),
} satisfies MockedObject<OrderRepository>;
//  ^^ satisfies validates against the interface without widening types

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(() => {
    vi.clearAllMocks();
    service = new OrderService(mockRepository);
  });

  it('returns the order when found', async () => {
    const order: Order = {
      id: toOrderId('ord_123'),   // ✅ Branded type — not plain string
      userId: toUserId('usr_456'),
      status: 'pending',
      total: toCents(9999),
      currency: 'USD',
      items: [],
      createdAt: new Date(),
    };

    mockRepository.findById.mockResolvedValueOnce(order);

    const result = await service.getOrderById(toOrderId('ord_123'));
    expect(result).toEqual(order);
    expect(mockRepository.findById).toHaveBeenCalledWith('ord_123');
  });

  it('throws OrderNotFoundError when order does not exist', async () => {
    mockRepository.findById.mockResolvedValueOnce(null);

    await expect(service.getOrderById(toOrderId('ord_999')))
      .rejects
      .toThrow(OrderNotFoundError);
  });
});

Quick Reference

Practice Rule
any Banned — use unknown, generics, or Zod
Type inference Infer locals — annotate parameters, return types, class members
Immutability readonly on params, interfaces, arrays; never mutate inputs
Null handling ?. and ?? — never \|\| for defaults
Error handling Narrow unknown err with instanceof before using
Type assertions Use sparingly — always add a comment explaining why
Branded types IDs, money, validated strings — prevent transposition bugs
Discriminated unions Model all state — loading/success/error — never booleans + null
Utility types Partial, Pick, Omit, Extract, Exclude — never rewrite
Generics For reusable functions and repos — constrain with extends
const type params Preserve literal types in generic functions (TS 5.0+)
satisfies Validate type + keep literals — prefer over as
never Exhaustiveness checks in switch — catches missing cases
using Automatic resource cleanup via Symbol.dispose (TS 5.2+)
Runtime validation Zod at all external boundaries — know z.input vs z.output
import type Type-only imports — keeps JS output clean
TSDoc Document all exported functions, classes, and complex types