# TypeScript

> Type-check TypeScript with tsc 7, narrow and validate untrusted data, pick tsconfig flags that catch real bugs, and fix common compiler errors.

Canonical: https://www.wiki.jodisand.me/typescript/
Reviewed: 2026-09-24
Related: [Testing](https://www.wiki.jodisand.me/testing/index.md), [System design](https://www.wiki.jodisand.me/design/index.md), [Python](https://www.wiki.jodisand.me/python/index.md), [Go](https://www.wiki.jodisand.me/go/index.md)


## Cheatsheet

| Task | Command or syntax |
| --- | --- |
| Type-check only, no output | `npx tsc --noEmit` |
| Watch while editing | `npx tsc --noEmit --watch` |
| Build a project with references | `npx tsc --build` |
| Print the resolved config | `npx tsc --showConfig` |
| Explain why a file is in the program | `npx tsc --noEmit --explainFiles` |
| Compiler timing and memory | `npx tsc --noEmit --extendedDiagnostics` |
| Run a `.ts` file directly | `node src/script.ts` (Node 22.18+, 23.6+) |
| Get the type of a value | `type T = typeof x` |
| Narrow an `unknown` | `typeof`, `instanceof`, `in`, or a type predicate |
| Exhaustive switch | `default: { const _x: never = value; }` |
| Keep literal types | `as const` |
| Keys of a type | `keyof T` |
| Value types of a type | `T[keyof T]` |
| All properties optional / required | `Partial<T>` / `Required<T>` |
| Subset of keys | `Pick<T, 'a' \| 'b'>` |
| Remove keys | `Omit<T, 'a'>` |
| Return type of a function | `ReturnType<typeof fn>` |
| Resolved value of a promise | `Awaited<ReturnType<typeof fn>>` |
| Check a literal against a type without widening | `const cfg = { ... } satisfies Config` |
| Runtime validation | `zod`, `valibot`, or a hand-written predicate |

## Compiler versions: TypeScript 6 and 7

TypeScript 7.0 is the compiler rewritten in Go. It installs from the same `typescript` package and the command is still `tsc`. Full builds run roughly 8 to 12 times faster than 6.0 according to the [7.0 release notes](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/). 7.0 has no stable programmatic API yet, so tools that import the compiler (some framework template checkers, codemods) need `@typescript/typescript6`, which provides `tsc6`, until 7.1.

TypeScript 6.0 changed several defaults, and 7.0 keeps them:

| Option | Old default | 6.0+ default |
| --- | --- | --- |
| `strict` | `false` | `true` |
| `module` | depends on `target` | `esnext` |
| `target` | `es5` | current ES year (`es2025` in 6.0) |
| `types` | every package under `node_modules/@types` | `[]` |
| `rootDir` | inferred from input files | directory containing `tsconfig.json` |
| `noUncheckedSideEffectImports` | `false` | `true` |

Options deprecated in 6.0 are errors in 7.0: `target: es5`, `downlevelIteration`, `moduleResolution: node` (node10) and `classic`, `module: amd`/`umd`/`system`/`none`, `baseUrl`, `outFile`, and setting `esModuleInterop`, `allowSyntheticDefaultImports` or `alwaysStrict` to `false`. Set `target` and `types` explicitly so upgrades do not change behaviour silently.

## Types describe compile time only

Types are erased when the code is emitted or stripped. `as` is an assertion, not a check: it silences the compiler and does nothing to the value. Data crossing a boundary (HTTP responses, `JSON.parse`, environment variables, database rows, `postMessage`) is `unknown` until code validates it.

```ts
const data = JSON.parse(body) as User;     // unchecked: the compiler trusts you

const parsed = UserSchema.safeParse(JSON.parse(body));   // zod: checked at runtime
if (!parsed.success) throw new BadRequest(parsed.error.message);
const user: User = parsed.data;            // the type now matches reality
```

A hand-written type predicate works when a schema library is not available. The compiler trusts the predicate's return value, so a wrong predicate is as unsafe as `as`.

```ts
function isUser(v: unknown): v is User {
  return typeof v === 'object' && v !== null
    && 'id' in v && typeof v.id === 'number';   // `in` narrows v to have `id`
}
```

## Narrowing

The compiler narrows a union inside a branch after a check it understands: `typeof`, `instanceof`, `in`, equality, truthiness, and type predicates.

```ts
function handle(input: string | number | null) {
  if (input === null) return;              // null excluded below
  if (typeof input === 'string') return input.toUpperCase();
  return input.toFixed(2);                 // number
}
```

A discriminated union has a shared literal field. Switching on it narrows each branch, and assigning the leftover value to `never` turns a missing case into a compile error.

```ts
type Result =
  | { status: 'ok'; data: User }
  | { status: 'error'; message: string };

function render(r: Result) {
  switch (r.status) {
    case 'ok': return r.data.name;
    case 'error': return r.message;
    default: { const _never: never = r; return _never; }   // breaks when a variant is added
  }
}
```

This is where TypeScript earns its cost: adding a variant produces an error at every place that has to handle it.

Narrowing of a `let` variable is dropped inside a callback when the variable is assigned again after the callback is created. Copy the narrowed value into a `const` first.

## Generics

Constrain type parameters so the body can use them. An unconstrained `T` only allows operations valid on every type.

```ts
function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  return Object.fromEntries(keys.map(k => [k, obj[k]])) as Pick<T, K>;
}

async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  let last: unknown;
  for (let i = 0; i < attempts; i++) {
    try { return await fn(); } catch (e) { last = e; await sleep(2 ** i * 100); }
  }
  throw last;
}
```

```ts
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
type Handler<E extends string> = `on${Capitalize<E>}`;        // template literal type
type Unwrap<T> = T extends Promise<infer U> ? U : T;          // conditional type with infer
```

Keep type-level code to what pays for itself. A conditional type nobody can read costs more than a slightly repetitive definition, and deep recursive types slow the checker (see [slow type-checking](#slow-type-checking)).

## tsconfig

A starting point for a Node service or library compiled by `tsc`:

```json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "types": ["node"],
    "sourceMap": true,
    "declaration": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"]
}
```

`module: nodenext` implies `moduleResolution: nodenext`. For code bundled by Vite, esbuild or similar, use `module: preserve`, which implies `moduleResolution: bundler`. See the [tsconfig reference](https://www.typescriptlang.org/tsconfig/) for every option.

| Option | What it catches or changes |
| --- | --- |
| `strict` | Umbrella for `noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`, `strictBindCallApply`, `strictPropertyInitialization`, `noImplicitThis`, `useUnknownInCatchVariables`. Default `true` from 6.0 |
| `noUncheckedIndexedAccess` | `arr[0]` and `record[key]` become `T \| undefined`. The highest-value flag outside `strict` |
| `exactOptionalPropertyTypes` | `{ a?: string }` no longer accepts `{ a: undefined }`. Distinguishes absent from undefined |
| `noImplicitOverride` | Requires `override` on methods that replace a base class method |
| `isolatedModules` | Errors on code a single-file transpiler (esbuild, swc, Node type stripping) cannot compile correctly |
| `verbatimModuleSyntax` | Imports without `type` are kept in output, `import type` is removed. Makes you mark type-only imports so no phantom runtime import remains |
| `erasableSyntaxOnly` | Errors on syntax that needs code generation: `enum`, runtime `namespace`, parameter properties, `import =`. Use it when Node runs the `.ts` files directly |
| `skipLibCheck` | Skips checking `.d.ts` files. Faster, and avoids errors from conflicting third-party declarations |
| `types` | Which `@types` packages load as globals. Default `[]` from 6.0, so list `node`, `jest`, etc. |

Type-checking and emitting are separate jobs in most projects: `tsc --noEmit` in CI, and esbuild, swc, tsup or Node itself for output. Single-file transpilers cannot see other files, which is why `isolatedModules` matters.

## Running TypeScript in Node

Node strips type annotations and runs `.ts` files without a flag from v22.18.0 and v23.6.0, and the feature is stable from v24.12.0 and v25.2.0 ([Node docs](https://nodejs.org/api/typescript.html)).

```sh
node src/script.ts        # strips types, does not type-check
```

What Node does not do:

- Type-check. Run `tsc --noEmit` separately.
- Read `tsconfig.json`. `paths` aliases do not work.
- Transform syntax that needs code generation: `enum`, `namespace` with runtime code, parameter properties, import aliases, decorators. These throw at load time. Set `erasableSyntaxOnly` so `tsc` reports them first.
- Load `.ts` files from `node_modules`. Publish compiled JavaScript.

Relative imports must include the extension (`import { x } from './x.ts'`). Node recommends this config for code it runs directly:

```json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}
```

Use `tsx` when you need `paths` or non-erasable syntax without a build step.

## Async and errors

```ts
const [a, b] = await Promise.all([fetchA(), fetchB()]);          // rejects on the first failure
const results = await Promise.allSettled([fetchA(), fetchB()]);  // never rejects; inspect each status
const first = await Promise.race([work(), timeout(5000)]);       // first to settle wins

const res = await fetch(url, { signal: AbortSignal.timeout(5000) });   // aborts after 5 s
```

`Promise.race` does not cancel the loser. Pass an `AbortSignal` to work that supports it.

Under `strict`, `catch (e)` gives `unknown`. Narrow it before use:

```ts
try { await work(); }
catch (e) {
  if (e instanceof Error) log.error(e.message, { stack: e.stack });
  else log.error('non-Error thrown', { value: String(e) });
}
```

A promise that rejects with no handler attached is an unhandled rejection. Since Node 15 the default `--unhandled-rejections=throw` terminates the process. Either `await` the promise, attach `.catch()`, or write `void promise` to mark a deliberate fire-and-forget (and handle errors inside it).

## Structuring types

```ts
// type for unions, functions and computed types; interface for object contracts others extend
type Method = 'GET' | 'POST';
interface Store { get(id: string): Promise<User | undefined>; }

// Branded types stop identifiers of the same primitive shape being swapped
type UserId = string & { readonly __brand: 'UserId' };
const asUserId = (s: string): UserId => s as UserId;

// readonly parameters document and enforce no mutation
function sum(values: readonly number[]): number { return values.reduce((a, b) => a + b, 0); }

// as const plus an indexed type replaces an enum
const ROLES = ['admin', 'user'] as const;
type Role = (typeof ROLES)[number];    // 'admin' | 'user'
```

`enum` generates runtime code, fails under `erasableSyntaxOnly`, and numeric enums accept any number. A `const` object or tuple with a derived union does the same job as plain JavaScript.

`satisfies` checks a value against a type while keeping the value's narrower inferred type:

```ts
const routes = { home: '/', user: '/u/:id' } satisfies Record<string, string>;
routes.user;   // string: the key is known. Annotating `: Record<string, string>` would lose the keys
```

## Environment variables and configuration

`process.env.X` is `string | undefined` whatever the declarations say. Parse and validate once at start-up, then pass a typed object around.

```ts
const port = Number(process.env.PORT ?? 8080);
if (!Number.isInteger(port)) throw new Error('PORT must be an integer');
```

## Utility types

The built-in utility types are mapped and conditional types the compiler ships; knowing which one already exists saves writing a worse copy. They compose: `Readonly<Partial<Pick<T, 'a'>>>` is an ordinary type.

| Type | Result |
| --- | --- |
| `Partial<T>` / `Required<T>` | Every property optional / required |
| `Readonly<T>` | Every property `readonly` (shallow) |
| `Record<K, V>` | Object with keys `K` and values `V`; `Record<string, never>` is an empty object |
| `Pick<T, K>` / `Omit<T, K>` | Keep / drop the named keys. `Omit` accepts any string, so a typo silently drops nothing |
| `Exclude<U, M>` / `Extract<U, M>` | Remove / keep union members assignable to `M` |
| `NonNullable<T>` | Strip `null` and `undefined` |
| `Parameters<F>` / `ReturnType<F>` | Tuple of argument types / return type of a function type |
| `ConstructorParameters<C>` / `InstanceType<C>` | The same for a class |
| `Awaited<T>` | Resolved type of a promise, recursively |
| `NoInfer<T>` | Blocks inference from that position (5.4+) so another argument decides `T` |
| `Uppercase`, `Lowercase`, `Capitalize`, `Uncapitalize` | String literal transforms for template literal types |

```ts
type Config = { host: string; port: number; tls?: { cert: string } };

type Overrides = Partial<Config>;                                  // merge over defaults
type Strict = Required<Pick<Config, 'tls'>> & Omit<Config, 'tls'>; // tls now mandatory
type Handlers = Record<Method, (req: Request) => Promise<Response>>;
type Ok<T> = Extract<T, { status: 'ok' }>;                         // pull one variant from a union

function withDefaults<T>(value: T, fallback: NoInfer<T>): T { return value ?? fallback; }   // T comes from value only

type KeysOfType<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];   // -? removes optionality
type StringKeys = KeysOfType<Config, string>;                     // 'host'
```

`Omit` on a union distributes badly (it operates on the common keys only); use a distributive helper `type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never` when the input is a union. `Readonly` and `Partial` are shallow; a `DeepReadonly` is a short recursive mapped type but slows the checker on large trees.

## ESM and CommonJS

Node decides how to load a file by extension and the nearest `package.json` `type` field: `.mjs` is always ESM, `.cjs` always CommonJS, `.js` follows `"type"`. TypeScript mirrors this with `.mts`/`.cts`/`.ts`, and under `module: nodenext` applies Node's rules to imports, which is where most extension and interop errors come from. From Node 22.12 an ESM module may be `require()`d from CommonJS as long as it has no top-level `await`, which removes the last reason to ship dual packages for most libraries.

```json
{
  "name": "@example/lib",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "engines": { "node": ">=22.12" }
}
```

```ts
import fs from 'node:fs/promises';              // default import of a CJS module: the module.exports object
import { readFile } from 'node:fs/promises';    // named imports work for Node built-ins and most CJS via cjs-module-lexer
import data from './data.json' with { type: 'json' };   // import attributes are mandatory for JSON in ESM
const require = createRequire(import.meta.url); // when a CJS-only dependency needs require()
const here = import.meta.dirname;               // Node 20.11+; replaces path.dirname(fileURLToPath(import.meta.url))
```

`exports` in `package.json` is a whitelist: anything not listed cannot be imported, including `dist/internal.js`. Put `types` first in each condition so TypeScript finds declarations. A `.d.ts` next to a `.js` is picked up automatically; `typesVersions` and separate `.d.cts` files are only needed for true dual packages. `verbatimModuleSyntax` plus `module: nodenext` gives an error when a `.cts` file uses `import` syntax that would become an ESM import at runtime.

## Node scripts

A TypeScript script that Node runs directly needs erasable syntax only, `.ts` extensions in relative imports and no `tsconfig` paths. The standard library covers argument parsing, subprocesses, file walking and fetch, so an operational script rarely needs a dependency.

```ts
#!/usr/bin/env node
import { parseArgs } from 'node:util';
import { readFile, writeFile, glob } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const { values, positionals } = parseArgs({
  options: { 'dry-run': { type: 'boolean', default: false }, out: { type: 'string', short: 'o' } },
  allowPositionals: true,
});

const run = promisify(execFile);
const { stdout } = await run('kubectl', ['get', 'pods', '-o', 'json'], { timeout: 30_000 });   // no shell, no injection
const pods = JSON.parse(stdout) as unknown;

for await (const file of glob('config/**/*.yaml')) {              // Node 22+: async iterator, no dependency
  if (!values['dry-run']) await writeFile(file, await transform(await readFile(file, 'utf8')));
}

process.exitCode = 0;                                              // set the code and let the loop drain, rather than process.exit()
```

```sh
node --env-file=.env src/script.ts          # load variables without dotenv (Node 20.6+)
node --watch src/server.ts                  # restart on change
node --test --experimental-strip-types      # older 22.x without default stripping
node --import ./register.ts src/main.ts     # preload hooks or instrumentation
NODE_OPTIONS='--enable-source-maps' node dist/main.js   # stack traces point at .ts lines
```

`process.exit()` cuts off pending stdout writes and open handles; set `process.exitCode` and return. Unhandled rejections and uncaught exceptions terminate the process by default; a `process.on('SIGTERM', ...)` handler that closes the server and lets the event loop empty is the graceful path.

## zod

zod defines a schema once and gives you both the runtime parser and the static type. Version 4 (2025) moved string formats to top-level functions (`z.email()`, `z.uuid()`, `z.url()`, `z.iso.datetime()`), replaced `.format()` and `.flatten()` on errors with `z.treeifyError()` and `z.prettifyError()`, and made `z.object` strip unknown keys by default (`z.strictObject` rejects them, `z.looseObject` keeps them).

```ts
import * as z from 'zod';

const Env = z.object({
  PORT: z.coerce.number().int().min(1).max(65535).default(8080),   // coerce: "8080" from the environment
  DATABASE_URL: z.url(),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  ADMIN_EMAIL: z.email().optional(),
});
export type Env = z.infer<typeof Env>;

const parsed = Env.safeParse(process.env);
if (!parsed.success) {
  console.error(z.prettifyError(parsed.error));                    // one line per problem, with the path
  process.exit(1);
}
export const env = parsed.data;

const Event = z.discriminatedUnion('type', [
  z.object({ type: z.literal('created'), id: z.uuid(), at: z.iso.datetime() }),
  z.object({ type: z.literal('deleted'), id: z.uuid() }),
]);

const Page = z.object({ items: z.array(Event), next: z.string().nullable() });
const Duration = z.string().regex(/^\d+(ms|s|m)$/).transform(parseDuration);   // output type differs from input
type DurationIn = z.input<typeof Duration>;                          // string
type DurationOut = z.output<typeof Duration>;                        // number
```

Validate at the boundary and pass the inferred type inward; do not re-validate on every function call. `.safeParse` returns a result object and never throws, which suits request handlers; `.parse` throws a `ZodError`, which suits start-up config where a crash is the right outcome. `z.infer` makes the schema the source of truth, so a type and its validator cannot drift. For very hot paths, `valibot` has the same shape with smaller bundles, and `typia` or `ajv` compile JSON Schema into faster validators.

## vitest

vitest runs tests through Vite's transform pipeline, so TypeScript, ESM and path aliases work with no extra config, and `vitest.config.ts` can extend an existing `vite.config.ts`. The API matches Jest (`describe`, `it`, `expect`, `vi.fn`), so most Jest suites move with an import change.

```ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts'],
    environment: 'node',
    restoreMocks: true,                       // reset vi.fn state between tests
    testTimeout: 10_000,
    coverage: {
      provider: 'v8',
      include: ['src/**'],
      thresholds: { lines: 80, functions: 80, branches: 70 },   // fail the run under these
    },
    typecheck: { enabled: true, include: ['src/**/*.test-d.ts'] },   // expectTypeOf assertions via tsc
  },
});
```

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('./client.ts', () => ({ fetchUser: vi.fn() }));            // hoisted; must be at module top level
import { fetchUser } from './client.ts';
import { greet } from './greet.ts';

describe('greet', () => {
  beforeEach(() => { vi.mocked(fetchUser).mockResolvedValue({ name: 'Ann' }); });

  it.each([
    ['ann', 'Hello, Ann'],
    ['bob', 'Hello, Bob'],
  ])('greets %s', async (id, expected) => {
    await expect(greet(id)).resolves.toBe(expected);
  });

  it('retries after a delay', async () => {
    vi.useFakeTimers();
    const p = retry(() => Promise.reject(new Error('x')), 2);
    await vi.advanceTimersByTimeAsync(1_000);                       // flush the setTimeout without waiting
    await expect(p).rejects.toThrow('x');
    vi.useRealTimers();
  });

  it('matches the rendered output', () => {
    expect(render(input)).toMatchSnapshot();                        // stored under __snapshots__/; update with -u
  });
});
```

```sh
npx vitest                                   # watch mode locally
npx vitest run --reporter=dot                # CI: single run
npx vitest run --coverage                    # v8 coverage with the configured thresholds
npx vitest run src/parse.test.ts -t 'empty'  # one file, tests matching a name
npx vitest run --changed origin/main         # tests affected by files changed since main
npx vitest run --update                      # accept changed snapshots (review the diff first)
npx vitest --ui                              # browser UI with module graph and console output
```

`vi.mock` calls are hoisted above imports, so factories cannot reference variables declared later in the file; `vi.hoisted` exists for that. Prefer injecting a dependency over mocking a module: a function that takes a `fetch`-shaped parameter is tested with a stub and never needs `vi.mock`. `expectTypeOf<Result>().toEqualTypeOf<{ ok: true }>()` in a `.test-d.ts` file asserts types when the type-level API is the product. See [Testing](https://www.wiki.jodisand.me/testing/) for what the unit and integration layers should each cover.

## Useful commands

```sh
# Type-check in CI with plain output
npx tsc --noEmit --pretty false

# Count implicit any errors before turning on noImplicitAny
npx tsc --noEmit --noImplicitAny 2>&1 | grep -c 'implicitly has an'

# Record a trace to find slow types (open in https://ui.perfetto.dev or chrome://tracing)
npx tsc --noEmit --generateTrace trace-out

# Generate types from a JSON sample
npx quicktype --lang ts --src sample.json --just-types

# Unused files, exports and dependencies
npx knip

# Circular imports
npx madge --circular --extensions ts src

# Bundle size of the built output in bytes
npx esbuild src/index.ts --bundle --minify --platform=node | wc -c

# Which package.json condition Node will use for an import
node -p "import.meta.resolve('@example/lib')" --input-type=module

# Outdated dependencies with the type of bump
npm outdated

# Check published package contents before publishing
npm pack --dry-run

# Verify a package's exports and types resolve under every module system
npx @arethetypeswrong/cli --pack .
```

## Snippets

Retry with exponential backoff and jitter, honouring an `AbortSignal` and retrying only the errors the caller says are transient.

```ts
export async function retry<T>(
  fn: (signal?: AbortSignal) => Promise<T>,
  { attempts = 5, baseMs = 200, maxMs = 10_000, signal, isRetryable = () => true }:
    { attempts?: number; baseMs?: number; maxMs?: number; signal?: AbortSignal; isRetryable?: (e: unknown) => boolean } = {},
): Promise<T> {
  for (let i = 0; ; i++) {
    try { return await fn(signal); }
    catch (e) {
      if (i + 1 >= attempts || !isRetryable(e)) throw e;
      const delay = Math.min(maxMs, baseMs * 2 ** i) * (0.5 + Math.random());
      await new Promise<void>((resolve, reject) => {
        const t = setTimeout(resolve, delay);
        signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason); }, { once: true });
      });
    }
  }
}
```

Bounded concurrency over a list without a library.

```ts
export async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
  const results: R[] = new Array(items.length);
  let next = 0;
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const i = next++;                                  // single-threaded: no race on the index
      results[i] = await fn(items[i]!);
    }
  }));
  return results;
}
```

`fetch` with a timeout, JSON body, status check and typed response through a schema.

```ts
export async function getJson<S extends z.ZodType>(url: string, schema: S, timeoutMs = 10_000): Promise<z.output<S>> {
  const res = await fetch(url, {
    headers: { accept: 'application/json', authorization: `Bearer ${process.env.API_TOKEN}` },
    signal: AbortSignal.timeout(timeoutMs),              // TimeoutError DOMException when exceeded
  });
  if (!res.ok) throw new Error(`GET ${url}: ${res.status} ${res.statusText}`);
  return schema.parse(await res.json());
}
```

Combine a caller's signal with a timeout so either cancels the request (Node 20+).

```ts
const signal = AbortSignal.any([callerSignal, AbortSignal.timeout(5_000)]);
```

Typed configuration loaded once, with a clear failure at start-up.

```ts
const Config = z.object({
  port: z.coerce.number().int().default(8080),
  databaseUrl: z.url(),
  shutdownGraceMs: z.coerce.number().default(15_000),
});
export const config = Config.parse({
  port: process.env.PORT,
  databaseUrl: process.env.DATABASE_URL,
  shutdownGraceMs: process.env.SHUTDOWN_GRACE_MS,
});                                                      // throws ZodError before anything starts listening
```

Graceful shutdown for an HTTP server: stop accepting, drain in-flight requests, force-exit on a deadline.

```ts
import { createServer } from 'node:http';

const server = createServer(handler).listen(config.port);

function shutdown(sig: string) {
  console.error(JSON.stringify({ level: 'info', msg: 'shutting down', sig }));
  server.close(() => { process.exitCode = 0; });         // resolves when the last keep-alive connection ends
  server.closeIdleConnections();
  setTimeout(() => { server.closeAllConnections(); process.exit(1); }, config.shutdownGraceMs).unref();
}
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));
```

Structured logging with `pino`, one child logger per request.

```ts
import pino from 'pino';

export const log = pino({ level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] });

const reqLog = log.child({ requestId: crypto.randomUUID(), path: req.url });
reqLog.info({ status: 200, ms: Date.now() - start }, 'handled');
reqLog.error({ err }, 'upstream failed');                // err key is serialised with stack and cause
```

A `Result` type for expected failures, so a function's error cases appear in its signature.

```ts
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

function parsePort(s: string): Result<number, 'not-a-number' | 'out-of-range'> {
  const n = Number(s);
  if (!Number.isInteger(n)) return err('not-a-number');
  if (n < 1 || n > 65535) return err('out-of-range');
  return ok(n);
}
```

Exhaustive handling of a union with a helper that reports the unexpected value at runtime too.

```ts
export function assertNever(x: never, message = 'unexpected value'): never {
  throw new Error(`${message}: ${JSON.stringify(x)}`);
}
```

Typed event emitter without a library.

```ts
type Events = { connected: [addr: string]; error: [err: Error]; closed: [] };

class Emitter<E extends Record<string, unknown[]>> {
  #handlers: { [K in keyof E]?: Set<(...args: E[K]) => void> } = {};
  on<K extends keyof E>(name: K, fn: (...args: E[K]) => void): () => void {
    (this.#handlers[name] ??= new Set()).add(fn);
    return () => this.#handlers[name]?.delete(fn);
  }
  emit<K extends keyof E>(name: K, ...args: E[K]): void { this.#handlers[name]?.forEach(fn => fn(...args)); }
}
const bus = new Emitter<Events>();
bus.on('connected', addr => addr.toUpperCase());         // addr: string
```

Read a large file line by line with a stream, no full load into memory.

```ts
import { createReadStream } from 'node:fs';
import readline from 'node:readline';

const rl = readline.createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of rl) {
  if (line.startsWith('#')) continue;
  handle(line);
}
```

Atomic file write: temp file beside the target, then rename.

```ts
import { writeFile, rename } from 'node:fs/promises';

export async function writeAtomic(path: string, data: string): Promise<void> {
  const tmp = `${path}.${process.pid}.tmp`;
  await writeFile(tmp, data, { encoding: 'utf8', flag: 'wx' });   // wx: fail if the temp file exists
  await rename(tmp, path);                                        // atomic on the same filesystem
}
```

Table-driven test that checks both success and error cases from one data set.

```ts
const cases: Array<{ input: string; want: number } | { input: string; error: string }> = [
  { input: '8080', want: 8080 },
  { input: '0', error: 'out-of-range' },
  { input: 'x', error: 'not-a-number' },
];

it.each(cases)('parsePort($input)', c => {
  const r = parsePort(c.input);
  if ('want' in c) expect(r).toEqual({ ok: true, value: c.want });
  else expect(r).toEqual({ ok: false, error: c.error });
});
```

Debounce with a typed signature and a cancel handle.

```ts
export function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number) {
  let t: ReturnType<typeof setTimeout> | undefined;
  const wrapped = (...args: A) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
  wrapped.cancel = () => clearTimeout(t);
  return wrapped;
}
```

## Troubleshooting

| Symptom | Likely cause | Check or fix |
| --- | --- | --- |
| `Cannot find name 'process'` or `describe` after upgrading to 6.0 or 7.0 | `types` now defaults to `[]` | Add `"types": ["node"]` (plus test framework types) |
| New errors everywhere after upgrading | `strict` now defaults to `true` | Fix them, or set `"strict": false` temporarily and enable flags one by one |
| `TS5102: Option 'baseUrl' has been removed` (or another option) in 7.0 | Option deprecated in 6.0 | Follow the hint in the message; rewrite `paths` relative to the tsconfig; move off `node10`/`classic` resolution |
| `TS2835: Relative import paths need explicit file extensions ... Did you mean './x.js'?` | `node16`/`nodenext` require extensions in relative ESM imports | Import `./x.js` (it resolves to `x.ts`), or `./x.ts` with `rewriteRelativeImportExtensions` |
| `TS1484: 'X' is a type and must be imported using a type-only import` | `verbatimModuleSyntax` | `import type { X }` or `import { type X }` |
| `Object is possibly 'undefined'` on array access | `noUncheckedIndexedAccess` | Check the element, or use `for...of` / `.at()` with a guard |
| Node throws `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` | `enum`, `namespace`, or parameter property in a stripped file | Replace with erasable syntax; enable `erasableSyntaxOnly` |
| Editor and CI disagree | Different TypeScript versions or tsconfig | `npx tsc --version`, `npx tsc --showConfig`; select the workspace TypeScript in the editor |
| A file is checked that you did not expect | `include` glob or an import pulls it in | `npx tsc --noEmit --explainFiles \| grep -A3 path/to/file` |
| Tool built on the compiler API fails with 7.0 | 7.0 has no stable API | Install `@typescript/typescript6` for that tool until 7.1 |
| `ERR_REQUIRE_ESM` or `ERR_MODULE_NOT_FOUND` | Wrong `type` in `package.json`, or importing a path not listed in `exports` | Check `"type"`, the file extension and the `exports` map; Node 22.12+ can `require()` ESM |
| `Cannot use import statement outside a module` | `.js` file treated as CommonJS | Add `"type": "module"` or rename to `.mjs` |
| `Cannot find module './x.js'` from `tsc` but the file is `x.ts` | `moduleResolution` is not `nodenext`/`node16` | Set `module: nodenext`; `.js` specifiers map to `.ts` sources |
| Default import of a CJS package is `{ default: ... }` | Interop difference between bundler and Node | Use `import pkg from` for the `module.exports` object; check the package's `exports` conditions |
| `vi.mock` factory throws `Cannot access before initialization` | Hoisting moved the mock above the variable it uses | Wrap the value in `vi.hoisted(() => ...)` |
| Tests pass alone, fail together | Shared module state or unrestored mocks | `restoreMocks: true`; avoid top-level mutable singletons |
| Snapshot test fails after a dependency bump | Serialised output changed | Inspect `git diff` of the snapshot, then `vitest -u` if intended |
| `ZodError` at start-up naming a key you set | Environment value is the wrong shape, or `.env` not loaded | `node --env-file=.env`; use `z.coerce` for numbers and booleans |
| Unknown keys vanish after `parse` | `z.object` strips by default | Use `z.strictObject` to reject or `z.looseObject` to keep them |
| `Type instantiation is excessively deep` | Recursive conditional or mapped type | Add a depth limit, use an interface, or simplify; see [slow type-checking](#slow-type-checking) |

### Slow type-checking

Run `npx tsc --noEmit --extendedDiagnostics` and look at `Check time`, `Types` and `Instantiations`. Very high instantiation counts point at recursive conditional or mapped types. Record a trace with `--generateTrace trace-out` to see which file and expression dominate. Common fixes: add explicit return types to exported functions, replace large unions of object types with interfaces, split giant files, and set `skipLibCheck: true`.


