# 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](https://quality.stereobooster.com/static-types.md).

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

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

## The strictness ratchet — `tsconfig.json`

Baseline that *should* be the default but isn't:

```jsonc
{
  "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.
- **`noUncheckedIndexedAccess`** — `arr[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.
- **`useUnknownInCatchVariables`** — `catch (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](https://quality.stereobooster.com/eslint.md)).
  Both are function-scoped; for unused *exports* and *files* you
  need whole-program reachability — see
  [Dead-code detection](https://quality.stereobooster.com/dead-code-detection.md).
- **`incremental`** + **`tsBuildInfoFile`** — `tsc` 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

```ts
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](https://quality.stereobooster.com/eslint.md)) for the same check enforced as
a lint rule.

### Opaque / branded types for invariants

```ts
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`

```ts
// `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

```ts
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](https://zod.dev/)'s `.safeParse` produces the same shape at parsing boundaries.

### Recursive `Json` type

```ts
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.

```bash
npm install zod
```

```ts
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-ts** — FP-style with `Either<Errors, A>` results.
  Natural fit if the codebase already uses fp-ts.
- **Effect.Schema** (part of [effect.website](https://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](https://quality.stereobooster.com/schema-and-boundary-validation.md).

## Test fixtures from schemas

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

```bash
npm install --save-dev zocker
```

```ts
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](https://quality.stereobooster.com/property-based-testing.md): 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-fest** — `npm i -D type-fest`. Utility types you'd
  otherwise hand-roll: `Simplify`, `RequireAtLeastOne`,
  `PartialDeep`, `Tagged` (built-in branding helper),
  `Promisable`, dozens more.
- **type-coverage** — `npx 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](https://quality.stereobooster.com/coverage.md) 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](https://quality.stereobooster.com/eslint.md).

## 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 codegen** — `openapi-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*](https://francisngo.github.io/blog/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](https://quality.stereobooster.com/static-types.md).

## Referenced by

- [Dead-code detection](https://quality.stereobooster.com/dead-code-detection.md) · Methods
- [Static types](https://quality.stereobooster.com/static-types.md) · Methods
- [Playwright](https://quality.stereobooster.com/playwright.md) · Recipes
- [Recipes](https://quality.stereobooster.com/recipes.md) · Recipes

## Acronyms

- FP — functional programming
