TypeScript
Type-check TypeScript with tsc 7, narrow and validate untrusted data, pick tsconfig flags that catch real bugs, and fix common compiler errors.
On this page
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. 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.
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.
| 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).
node src/script.ts # strips types, does not type-checkWhat Node does not do:
- Type-check. Run
tsc --noEmitseparately. - Read
tsconfig.json.pathsaliases do not work. - Transform syntax that needs code generation:
enum,namespacewith runtime code, parameter properties, import aliases, decorators. These throw at load time. SeterasableSyntaxOnlysotscreports them first. - Load
.tsfiles fromnode_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.
| 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 |
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 linesprocess.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 outputvi.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#
| 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#
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.