Skip to content

Software Quality

TypeScript

TypeScript is a gradual type system for JavaScript: annotations are checked before the code runs, then erased. What they reach is representation, null, and shape errors, not logic ones; nothing is enforced at runtime. That slice of real bugs has been measured, and so has the absence of an effect on overall defect rates — both in static types.

This page targets TypeScript 6.x on Node LTS. Default tsconfig leaves most of the type system off; the recipe is the ratchet that turns it on.

Setup

npm install --save-dev typescript @types/node
npx tsc --init

The strictness ratchet — tsconfig.json

Baseline that should be the default but isn't:

{
  "compilerOptions": {
    // ===== The big one =====
    "strict": true,

    // ===== Additional strictness flags worth adding day one =====
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,
    "noFallthroughCasesInSwitch": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    // Unused locals/params — off by default here; the flag notes say why.
    // "noUnusedLocals": true,
    // "noUnusedParameters": true,

    // ===== Module / target =====
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "verbatimModuleSyntax": true,
    "esModuleInterop": true,
    "skipLibCheck": true,

    // ===== Output =====
    "outDir": "dist",
    "declaration": true,
    "sourceMap": true,

    // ===== Incremental builds =====
    "incremental": true,
    "tsBuildInfoFile": "node_modules/.cache/tsc/.tsbuildinfo"
  },
  "include": ["src"]
}

What each non-obvious flag catches:

  • strict turns on strictNullChecks, strictFunctionTypes, noImplicitAny, strictPropertyInitialization, and the rest of the strictness family. Without it, the type system is mostly cosmetic.
  • noUncheckedIndexedAccessarr[i] returns T | undefined rather than T. Catches off-by-one and missing-key bugs the default config silently allows.
  • exactOptionalPropertyTypes{ x?: number } no longer also accepts { x: undefined }. Without it, "optional" and "explicitly-undefined" are conflated; with it, they aren't.
  • useUnknownInCatchVariablescatch (e) types e as unknown, not any. Forces a narrowing step before using the error value.
  • verbatimModuleSyntax — type imports must be marked import type; the emitted JS contains only the runtime imports. Prevents subtle bundler bugs from side-effecting type imports.
  • noUnusedLocals / noUnusedParameters (commented out in the baseline) — tsc flags unused local variables and function parameters with no extra tool. Left off here because they are build-blocking and all-or-nothing: commenting out one line mid-edit fails the type check. @typescript-eslint/no-unused-vars catches the same cases with finer control (fixable, argsIgnorePattern: "^_" for deliberately-unused args — see ESLint). Both are function-scoped; for unused exports and files you need whole-program reachability — see Dead-code detection.
  • incremental + tsBuildInfoFiletsc writes type-check state to disk and reuses it on the next run, re-checking only files that changed and their dependents. Pays off the moment clean builds take more than a few seconds. Pointing the buildinfo at node_modules/.cache/ keeps it out of source control without an extra .gitignore entry — wipe-on-clean by virtue of being under node_modules/. For multi-package monorepos the next step is composite: true plus references; out of scope here.

Core patterns

These patterns each rule out a category of bug the type system can eliminate by construction.

Sum types and exhaustive matching

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "triangle"; base: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":   return Math.PI * s.radius ** 2;
    case "square":   return s.side ** 2;
    case "triangle": return (s.base * s.height) / 2;
    default: {
      const _exhaustive: never = s;
      throw new Error(`unhandled: ${_exhaustive}`);
    }
  }
}

Add a new variant; the compiler fails the never assignment and lists exactly which switch to update. Pair with @typescript-eslint/switch-exhaustiveness-check (see ESLint) for the same check enforced as a lint rule.

Opaque / branded types for invariants

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

function asUserId(s: string): UserId { return s as UserId; }
function asEmail(s: string): Email  { return s as Email; }

function sendInvite(to: Email, from: UserId): void { /* ... */ }

const a = asEmail("hi@example.com");
const b = asUserId("u_123");
sendInvite(a, b);   // OK
sendInvite(b, a);   // type error — arguments swapped

The brand exists only at the type level — no runtime cost. Construct the branded value once at a boundary (with validation), use it freely after.

as const + satisfies

// `as const` narrows to literal types.
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];   // "admin" | "editor" | "viewer"

// `satisfies` checks shape without widening.
const config = {
  retries: 3,
  endpoints: { api: "/api", auth: "/auth" },
} satisfies { retries: number; endpoints: Record<string, string> };

config.endpoints.api;   // type stays the literal "/api", not string

satisfies is the right tool when you want a value to conform to a shape but keep the narrow types the literal provides.

Result / discriminated-union API modeling

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

async function fetchUser(id: UserId): Promise<Result<User>> {
  try {
    const r = await fetch(`/api/users/${id}`);
    if (!r.ok) return { ok: false, error: new Error(`HTTP ${r.status}`) };
    return { ok: true, value: await r.json() };
  } catch (e: unknown) {
    return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
  }
}

Result forces the caller to handle the failure case at the type level. Zod's .safeParse produces the same shape at parsing boundaries.

Recursive Json type

type Json =
  | string | number | boolean | null
  | Json[]
  | { [key: string]: Json };

Json is the type for arbitrary JSON inspected at runtime; unlike any, it keeps the structure checkable.

Boundary validation: Zod

The type system stops at the program boundary. For data arriving from the network, the filesystem, environment variables or user input, TypeScript knows only the declared type, not the runtime shape. A runtime parser closes the gap: it produces a typed value or fails.

npm install zod
import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().nonnegative().optional(),
});

type User = z.infer<typeof UserSchema>;

// .parse — throws on invalid; use when failure is exceptional.
const u: User = UserSchema.parse(await req.json());

// .safeParse — returns a Result-shaped object; use at boundaries
// where invalid input is a normal case.
const result = UserSchema.safeParse(await req.json());
if (!result.success) {
  return new Response(JSON.stringify(result.error.issues), { status: 400 });
}
const user = result.data;

Common patterns:

  • HTTP-payload validation.parse request bodies before they touch business logic.
  • Env-var parsing — define a schema for process.env, parse once at startup, fail loud on missing vars.
  • Form parsing.safeParse form-data and surface result.error.issues as field-level errors.

Alternatives:

  • valibot — smaller bundle, pipeline-style. Worth the switch when bundle size dominates (edge functions, browsers).
  • arktype — faster runtime validation, TypeScript-like syntax in string form. Still under active development.
  • io-tsFP-style with Either<Errors, A> results. Natural fit if the codebase already uses fp-ts.
  • Effect.Schema (part of effect.website) — larger-scope alternative covering schema, error handling, and HTTP in one library. Worth the install only if you're buying into the Effect runtime broadly.

For why boundary validation matters, see schema and boundary validation.

Test fixtures from schemas

Once a Zod schema exists, fixtures can be generated from it directly:

npm install --save-dev zocker
import { zocker } from "zocker";
import { UserSchema } from "./user.ts";

// One sample.
const sample = zocker(UserSchema).generate();

// A batch — useful as a property-test input source.
const samples = Array.from({ length: 100 }, () =>
  zocker(UserSchema).generate(),
);

test("user-handling code accepts any schema-valid user", () => {
  for (const u of samples) expect(handleUser(u)).not.toThrow();
});

Alternative: zod-fixture — similar shape, different randomness controls. Pick one, stick to it.

Generated fixtures are not property-based testing: there is no shrinking, so a failing sample stays as large as it was generated.

Utility / DX libraries

Libraries with a narrow, stable footprint:

  • type-festnpm i -D type-fest. Utility types you'd otherwise hand-roll: Simplify, RequireAtLeastOne, PartialDeep, Tagged (built-in branding helper), Promisable, dozens more.
  • type-coveragenpx type-coverage reports the percentage of expressions with non-any types. Useful as the delta signal on PRs (did this patch widen the any-shaped hole?) — see Coverage for why delta as signal beats absolute as target.
  • pretty-ts-errors — VS Code extension; reformats TypeScript's dense error messages into scannable form. Quality-of-life, not correctness.
  • typescript-eslint with recommendedTypeChecked — the type-aware rule subset catches what the compiler doesn't (floating promises, misused promises, exhaustive switches). See ESLint.

End-to-end types

End-to-end typing shares TypeScript types between client and server, so a change to one cascades through both. The promise is "refactor an API and the call site fails to compile." The cost is front-end / back-end coupling — the FE can't be deployed independently, the BE can't change shape without breaking the FE, and HTTP-caching semantics (intermediaries, ETags, varied Accept headers) become harder to reason about.

When transparent RPC fits: monorepo, small team, fast iteration, no public API consumers. When it doesn't: multi-team boundaries, public API, third-party consumers, infra that relies on HTTP semantics.

Tools in the space, by shape:

  • tRPC — TypeScript-native RPC. No schema layer; client imports the type of the server router and gets typed procedure calls. Best fit when both ends are TypeScript and in the same repo.
  • Hono RPC — built into the Hono web framework. Web-standards-based, lighter, less ceremony than tRPC.
  • OpenAPI codegenopenapi-typescript, orval, ts-rest. The right choice when the API is the contract (public, polyglot consumers). The schema is the source of truth; types are generated from it.
  • React Server Components"use server" directive turns a server function into a client-callable. Transparent RPC scoped to one framework; same coupling tradeoff.

Gotchas

Type escape hatches

any vs unknown. unknown is a value of unknown type — you must narrow before using. any turns off the type checker for that expression and everything downstream. Use unknown at boundaries; reserve any for genuinely irreducible escape hatches and add a comment. Enforced by @typescript-eslint/no-explicit-any (forbids the annotation) plus the no-unsafe-* family in recommendedTypeChecked (catches any that leaked in via an untyped dependency or JSON.parse).

as assertions are unchecked. x as Foo tells the compiler "trust me" with no runtime check. Almost every use should be either (a) narrowing inside a guard, (b) constructing a branded type immediately after validation, or (c) interop with a typed third-party API that lost its types. Other uses introduce a gap between the static type and runtime value that the type checker can't catch. @typescript-eslint/consistent-type-assertions (with assertionStyle: "never") forbids as outright; the same package's no-non-null-assertion covers the ! shorthand.

Narrowing and validation

Assertion functions vs type guards. A type guard (x: unknown): x is Foo returns a boolean; an assertion function (x: unknown): asserts x is Foo throws or returns void. Use the guard when the caller branches; use the assertion when the caller assumes success after the call.

JSON round-trips lose nominal types. JSON.parse is typed as returning any by default. (ts-reset — @total-typescript/ts-reset — tightens it to unknown, along with Array.includes, fetch().json, and Array.filter(Boolean). Install with npm i -D, import once.) A branded UserId doesn't survive the trip.

Hand-rolled type guards drift from the types they claim to check. A guard's body is not checked against the type it proves, so the two diverge silently.

Module resolution

moduleResolution must match the toolchain. bundler vs node16 / nodenext affect how relative imports and package.json exports resolve. Pick one based on your toolchain (Vite / esbuild → bundler; tsc-to-node → nodenext) and don't mix.

Unsoundness

Even with the full strictness ratchet, TypeScript is intentionally unsound — a passing check is evidence, not proof. The strictness flags narrow the gap; they do not close it. TypeScript trades soundness for backward compatibility with the JavaScript ecosystem it had to adopt.

Some holes are avoidable by disciplined code — any, as, JSON boundaries. The structural ones have no user-land fix:

  • Array (and mutable-container) variance. string[] is assignable to (string | number)[]; the aliased reference can push a number into the original string[]. No flag rejects this — mutable containers are unsound under width subtyping.

Walkthrough with runnable examples: Francis Ngo — Understanding TypeScript Unsoundness and Caveats. The wider framing — soundness as a property, TypeScript as the canonical unsound-by-design gradual tier, and the empirical record — is in Static types.

Referenced by