TypeScript can catch many programming mistakes before runtime, but its types are erased when JavaScript executes. A type annotation does not validate an HTTP response, local storage value or message received from another process.

The strongest TypeScript code makes internal states explicit and validates data when it crosses a runtime boundary.

1. Model states with discriminated unions

Avoid a group of optional fields that allows impossible combinations:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

The status field narrows the available properties:

function describe<T>(state: RequestState<T>): string {
  switch (state.status) {
    case "idle": return "Not started";
    case "loading": return "Loading";
    case "success": return "Complete";
    case "error": return state.message;
  }
}

2. Make switches exhaustive

A helper using never makes new variants visible during compilation:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${String(value)}`);
}

Use it in the default branch when a union must be handled completely. Compiler settings such as strict and noImplicitReturns strengthen the feedback.

3. Prefer unknown at trust boundaries

The any type disables checking. Use unknown for data whose shape has not yet been established:

function parseJson(input: string): unknown {
  return JSON.parse(input);
}

Then validate before using it. A runtime schema library can help, but a small explicit type guard is sufficient for simple structures.

4. Validate external data at runtime

Types do not make a remote API trustworthy. Validate its response before passing it into the rest of the application. Reject malformed data rather than silently coercing it into a shape the program expects.

Keep validation at boundaries: HTTP responses, environment variables, persisted values, message queues and user input.

5. Use satisfies to check configuration

The satisfies operator checks compatibility without widening away useful literal types:

type RouteName = "home" | "settings";

const routes = {
  home: "/",
  settings: "/settings",
} satisfies Record<RouteName, string>;

This catches missing or misspelled keys while preserving the inferred values.

6. Brand identifiers when they must not be mixed

Plain strings make it easy to pass a user ID where an order ID is expected. A branded type can distinguish them:

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

Create branded values only through validated constructors. A cast at every call site defeats the purpose.

7. Keep asynchronous results explicit

Represent expected failure as data when callers need to handle it:

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

Use exceptions for exceptional failures and a result type when failure is part of normal control flow. Be consistent within a module.

Compiler settings and limits

Start with strict: true. Consider noUncheckedIndexedAccess and exactOptionalPropertyTypes where the codebase can support them. Adopt stricter options incrementally if legacy code makes an all-at-once migration impractical.

No type system removes the need for tests. TypeScript checks relationships in the program; tests check behaviour, and runtime validation checks data that arrives from outside it.

References: TypeScript Handbook, TypeScript TSConfig reference.