Software Engineering WikiSE Wiki

TypeScript

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

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand or syntax
Type-check only, no outputnpx tsc --noEmit
Watch while editingnpx tsc --noEmit --watch
Build a project with referencesnpx tsc --build
Print the resolved confignpx tsc --showConfig
Explain why a file is in the programnpx tsc --noEmit --explainFiles
Compiler timing and memorynpx tsc --noEmit --extendedDiagnostics
Run a .ts file directlynode src/script.ts (Node 22.18+, 23.6+)
Get the type of a valuetype T = typeof x
Narrow an unknowntypeof, instanceof, in, or a type predicate
Exhaustive switchdefault: { const _x: never = value; }
Keep literal typesas const
Keys of a typekeyof T
Value types of a typeT[keyof T]
All properties optional / requiredPartial<T> / Required<T>
Subset of keysPick<T, 'a' | 'b'>
Remove keysOmit<T, 'a'>
Return type of a functionReturnType<typeof fn>
Resolved value of a promiseAwaited<ReturnType<typeof fn>>
Check a literal against a type without wideningconst cfg = { ... } satisfies Config
Runtime validationzod, 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. 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:

OptionOld default6.0+ default
strictfalsetrue
moduledepends on targetesnext
targetes5current ES year (es2025 in 6.0)
typesevery package under node_modules/@types[]
rootDirinferred from input filesdirectory containing tsconfig.json
noUncheckedSideEffectImportsfalsetrue

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.

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.

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.

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.

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.

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;
}
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).

tsconfig#

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

{
  "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 for every option.

OptionWhat it catches or changes
strictUmbrella for noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables. Default true from 6.0
noUncheckedIndexedAccessarr[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
noImplicitOverrideRequires override on methods that replace a base class method
isolatedModulesErrors on code a single-file transpiler (esbuild, swc, Node type stripping) cannot compile correctly
verbatimModuleSyntaxImports without type are kept in output, import type is removed. Makes you mark type-only imports so no phantom runtime import remains
erasableSyntaxOnlyErrors on syntax that needs code generation: enum, runtime namespace, parameter properties, import =. Use it when Node runs the .ts files directly
skipLibCheckSkips checking .d.ts files. Faster, and avoids errors from conflicting third-party declarations
typesWhich @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).

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:

{
  "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#

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:

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#

// 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:

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.

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.

TypeResult
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, UncapitalizeString literal transforms for template literal types
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.

{
  "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" }
}
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.

#!/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()
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).

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.

// 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
  },
});
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
  });
});
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 for what the unit and integration layers should each cover.

Useful commands#

# 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.

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.

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.

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+).

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

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

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.

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.

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.

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.

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

Typed event emitter without a library.

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.

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.

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.

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.

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#

SymptomLikely causeCheck or fix
Cannot find name 'process' or describe after upgrading to 6.0 or 7.0types now defaults to []Add "types": ["node"] (plus test framework types)
New errors everywhere after upgradingstrict now defaults to trueFix them, or set "strict": false temporarily and enable flags one by one
TS5102: Option 'baseUrl' has been removed (or another option) in 7.0Option deprecated in 6.0Follow 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 importsImport ./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 importverbatimModuleSyntaximport type { X } or import { type X }
Object is possibly 'undefined' on array accessnoUncheckedIndexedAccessCheck the element, or use for...of / .at() with a guard
Node throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAXenum, namespace, or parameter property in a stripped fileReplace with erasable syntax; enable erasableSyntaxOnly
Editor and CI disagreeDifferent TypeScript versions or tsconfignpx tsc --version, npx tsc --showConfig; select the workspace TypeScript in the editor
A file is checked that you did not expectinclude glob or an import pulls it innpx tsc --noEmit --explainFiles | grep -A3 path/to/file
Tool built on the compiler API fails with 7.07.0 has no stable APIInstall @typescript/typescript6 for that tool until 7.1
ERR_REQUIRE_ESM or ERR_MODULE_NOT_FOUNDWrong type in package.json, or importing a path not listed in exportsCheck "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 CommonJSAdd "type": "module" or rename to .mjs
Cannot find module './x.js' from tsc but the file is x.tsmoduleResolution is not nodenext/node16Set module: nodenext; .js specifiers map to .ts sources
Default import of a CJS package is { default: ... }Interop difference between bundler and NodeUse import pkg from for the module.exports object; check the package’s exports conditions
vi.mock factory throws Cannot access before initializationHoisting moved the mock above the variable it usesWrap the value in vi.hoisted(() => ...)
Tests pass alone, fail togetherShared module state or unrestored mocksrestoreMocks: true; avoid top-level mutable singletons
Snapshot test fails after a dependency bumpSerialised output changedInspect git diff of the snapshot, then vitest -u if intended
ZodError at start-up naming a key you setEnvironment value is the wrong shape, or .env not loadednode --env-file=.env; use z.coerce for numbers and booleans
Unknown keys vanish after parsez.object strips by defaultUse z.strictObject to reject or z.looseObject to keep them
Type instantiation is excessively deepRecursive conditional or mapped typeAdd a depth limit, use an interface, or simplify; see 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.