typescript
TypeScript reference pack (TS 5.9 through 7.x era) covering the type system, modules and build strategy, React, major frameworks, runtimes, Node backends, testing, quality tooling, security, and the 2026 ecosystem state. Use when writing, reviewing, refactoring, debugging, or migrating non-trivial TypeScript: advanced types and declaration files, ESM/CJS interop and dual publishing, React server components and hooks, Next/Vue/Svelte/Angular choices, runtime selection across Node/Bun/Deno/edge, Fastify/Hono/NestJS services, Vitest/Playwright strategy, strict tsconfig and lint hardening, or supply-chain and web security. Depth lives in references/, one topic per file.
TypeScript
TypeScript is the default typed layer of the JavaScript ecosystem: browsers, Node.js, Bun, Deno, edge workers, build tooling, and now the compilers themselves (the native TS 7 line). A senior engineer moves fluently between type-level design, module topology, runtime behavior, and the toolchain that binds them.
This pack is an entrypoint plus ten deep references. Read the core rules first; they encode defaults that rarely deserve debate. Then load only the references the task needs. Each reference starts with a one-line scope and carries version floors, decision tables, runnable snippets, anti-patterns, and checklists.
Content targets the stable ecosystem of 2026: TypeScript 5.x with the native 7.x line arriving, Node.js 22/24 LTS, Bun 1.2+, Deno 2.x, React 19+, Vue 3.5+, Svelte 5, Angular 20-era signals, and the current toolchain (Vite/Rolldown, Vitest, Playwright, ESLint 9 flat config, Biome 2, pnpm). Versions move monthly: treat stated numbers as floors and verify upstream before quoting them in deliverables.
Non-negotiable core rules
"strict": trueis the floor, not the goal. AddnoUncheckedIndexedAccess,exactOptionalPropertyTypes, and the rest of the hardening set from 08-quality-tooling; annotate deliberate escape hatches with a reason.- Types are erased. Every I/O boundary (HTTP, env, storage, JSON.parse, postMessage) is parsed with a runtime validator before it becomes a typed value. See 06-backend-node.
unknownis the default for external data;anyis a lint error. Non-null assertions are acceptable only immediately after a checked guard.- Bundlers and transpilers (esbuild, swc, oxc, Vite, Bun) do not type-check. CI runs
tsc --noEmit(or the native TS 7 compiler) as a separate gate; see 08-quality-tooling. - New packages are ESM-first with a single explicit
exportsmap. Dual publishing is a deliberate compatibility cost, not a default; see 02-modules-build. verbatimModuleSyntaxis on; type-only imports useimport type; a package does not silently mixrequireand top-levelawaitsemantics.- Model states as discriminated unions and parse into them. Prefer literal unions and
as constobjects overenum; prefersatisfiesover widening annotations. See 01-type-system. - Errors are values or typed exceptions at boundaries, never
catch (e: any). Catch clauses takeunknown; map to domain errors exactly once. - No secrets cross into client bundles. Anything exposed through a client-visible prefix (for example
NEXT_PUBLIC_,VITE_) is public forever; see 09-security. - React code obeys the rules of hooks and makes the server/client boundary explicit; fetching belongs to the framework or a query library, not to
useEffect. See 03-react. - Tests are deterministic: fixed clocks, seeded randomness, isolated state, no shared mutable fixtures. See 07-testing.
- Lockfiles are committed; dependencies are audited in CI and updated on a schedule; published packages use provenance/OIDC trusted publishing. See 09-security.
- One TypeScript version per repository; toolchain versions are pinned. "Works on my machine" is a bug report, not a resolution.
Load strategy
- Start with the index below and load at most two references per task; the rules above already cover most reviews.
- Combine one language reference (01-type-system or 02-modules-build) with one application reference (React, frameworks, backend, testing) when a task spans both.
- 10-ecosystem-2026 is the orientation and final-review pass; load it when versions, migrations, or a whole-repo audit are in scope.
Decision tables
Type modeling
| Need | Use | Avoid |
| --- | --- | --- |
| Closed set of states | Discriminated union + exhaustive switch | boolean flag pairs |
| Open set of string values | string with runtime validation | giant literal union |
| Named constants | as const object or literal union | enum |
| Object with defaults | Function returning a full object | exported mutable singleton |
| Opaque identifier | Branded type + constructor/parser | bare string everywhere |
| Complex inferred shape | satisfies against a type | annotation that widens |
| Deep generic machinery | Small, tested type tests | 200-line conditional types in app code |
Module and build strategy
| Situation | module / moduleResolution | Notes |
| --- | --- | --- |
| App built by Vite/Rollup | ESNext / bundler | bundler owns resolution |
| Node app, native ESM | nodenext / nodenext | full ESM semantics |
| Node with require(esm) needs | nodenext | set "type" correctly |
| Library, dual publish | node16-compatible + tsdown/tsup | test both import and require |
| Legacy CJS-only consumer | last resort | isolate in one adapter package |
Runtime selection
| Need | Default | Alternative |
| --- | --- | --- |
| General backend / LTS | Node.js 24 LTS | Node 22 LTS |
| Max dev speed, single binary | Bun 1.2+ | Node + swc |
| Permissions, built-in tooling | Deno 2 | Node |
| Global low-latency edge | Cloudflare Workers | Deno Deploy, Vercel Edge |
| Scripts and CLIs | Node or Bun | Deno with --allow-* |
Framework selection
| Project type | Default | Alternative | | --- | --- | --- | | Full-stack React, content + app | Next.js App Router | TanStack Start, React Router 7 | | Lightweight multi-runtime API | Hono | Fastify | | Structured enterprise service | NestJS | Fastify + manual DI | | Content-first, minimal JS | Astro | SvelteKit static | | Vue product app | Nuxt 3.5+ / Vite | Vue SPA | | Svelte product app | SvelteKit + runes | Svelte SPA |
Testing stack
| Layer | Tool | Notes |
| --- | --- | --- |
| Unit / component | Vitest + Testing Library | jsdom or browser mode |
| Type-level | expect-type inside Vitest | tsc --noEmit in CI |
| E2E | Playwright | trace + sharding |
| HTTP mocking | MSW | never hand-rolled fetch stubs |
| Database | Testcontainers | real engine, disposable |
| Property-based | fast-check | seeded, shrinking |
Reference index
| File | Scope | Load when |
| --- | --- | --- |
| 01-type-system.md | Advanced types: conditional/mapped/template-literal, variance, branding, satisfies, const params, infer, type testing, declarations | Designing public type APIs or debugging type errors and compiler performance |
| 02-modules-build.md | ESM/CJS, moduleResolution, exports maps, verbatimModuleSyntax, tsc vs swc/esbuild/oxc, tsdown/tsup, dual publishing, npm/JSR | Packaging or consuming libraries, fixing resolution errors, publishing |
| 03-react.md | React 19+, Server Components, hooks, Zustand, TanStack Query, Server Actions, react-hook-form + zod, performance, Vitest + RTL | Writing or reviewing React application code |
| 04-frameworks.md | Next.js App Router, Vue 3.5+, Svelte 5 runes, Angular signals, Solid, Astro; selection table | Choosing or working inside a meta-framework |
| 05-runtimes.md | Node LTS, Bun, Deno 2, edge runtimes; TS support, compatibility, deployment | Target runtime decisions and portability bugs |
| 06-backend-node.md | Fastify/Hono/NestJS, validation, Prisma/Drizzle/Kysely, auth, queues, WebSockets, config, shutdown, observability | Building or reviewing a TypeScript backend service |
| 07-testing.md | Vitest, Playwright, mocking, type testing, fast-check, Testcontainers, coverage, CI sharding, flake control | Designing or repairing a test suite |
| 08-quality-tooling.md | tsconfig hardening per flag, ESLint 9 flat config, Biome/oxlint, Prettier, CI checks, bundle analysis, monorepos | Setting up or tightening project quality gates |
| 09-security.md | npm supply chain, XSS/Trusted Types, CSP, secrets, SSRF, Node hardening, dependency policy | Reviewing security posture or handling an incident |
| 10-ecosystem-2026.md | Ecosystem state, migrations (CJS to ESM, signals, React 19), pitfalls, senior review checklist | Orienting on current versions, planning a migration, final review pass |
Port
- Port id —
skill://senior/typescript(version inmetadata.port-version). - Kind — read-only reference pack; no side effects, no tools required.
- Entrypoint — this
SKILL.md; depth lives inreferences/. - Load modes
- Installed agent:
skill({ name: "typescript" })in OpenCode; Claude Code reads<skills-dir>/typescript/SKILL.md. - Orchestrator: read
SKILL.md, then load only the references the task needs. - Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
- Installed agent:
- Consumer contract —
metadata.consumerslists the agents that may load it; consumers reference it asload skill typescript (optional). - Stability —
stable; breaking changes bumpport-versionmajor.
References (10)
01-type-system
Type System, Advanced
Scope: conditional, mapped, and template-literal types, variance, branding,
satisfies,consttype parameters,infer, type-level testing, and declaration patterns — with the limits of each.
Version floors (verify upstream; treat as minimums):
| Feature | Introduced | Notes |
| --- | --- | --- |
| satisfies | TS 4.9 | annotation checks without widening |
| const type parameters | TS 5.0 | preserves literal inference in generics |
| using / Symbol.dispose | TS 5.2 | needs lib: esnext.disposable |
| NoInfer<T> | TS 5.4 | stops inference at a position |
| --isolatedDeclarations | TS 5.5 | explicit return types for fast DTS |
| Inferred type predicates | TS 5.5 | functions returning boolean can narrow |
| --erasableSyntaxOnly | TS 5.8 | rejects enums/namespaces/parameter properties |
| Native compiler (TS 7 line) | announced/preview | same types, Go-based; verify upstream |
1. Structural typing, narrowing, and the erasure rule
TypeScript is structural: assignability is about shape, not names. The compiler narrows in if, switch, in, instanceof, and via user-defined predicates. None of it survives to runtime.
Rules that prevent most type bugs:
- Every external value starts as
unknownand is parsed into a type (see 06-backend-node). - Narrow with equality to a literal,
typeof,instanceof, or a predicate — not withas. - Prefer
interfacefor public object shapes that may be extended;typefor unions, mapped/conditional types, and anything needing intersection. readonlyis shallow; deep immutability is a modeled choice, not a modifier.
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
function unwrap<T>(r: Result<T>): T {
if (r.ok) return r.value;
throw r.error;
}
2. Conditional types
Conditional types select a type from a type. They distribute over naked type parameters, which is the source of both power and surprise.
type ElementType<T> = T extends readonly (infer U)[] ? U : never;
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type IsArray<T> = T extends readonly unknown[] ? true : false;
// Non-distributive: wrap both sides in tuples
type IsNever<T> = [T] extends [never] ? true : false;
type IsUnion<T, U = T> = T extends U ? ([U] extends [T] ? false : true) : never;
Distributivity rules:
| Pattern | Behavior |
| --- | --- |
| T extends U ? X : Y with naked T | distributes over union members |
| [T] extends [U] ? X : Y | checks the union as a whole |
| T & { } extends U | distributes unless T is never |
| any in the check position | resolves to the union of both branches |
Common built-ins worth knowing instead of reimplementing: Awaited, Exclude, Extract, NonNullable, ReturnType, Parameters, InstanceType, ThisParameterType, OmitThisParameter, Uppercase family, NoInfer.
Anti-pattern: nested conditionals that return never for mismatches. Prefer a small named alias per step and a type test per step (section 9).
3. Mapped types
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type RequiredBy<T, K extends keyof T> = Omit<T, K> & { [P in K]-?: T[P] };
| Modifier syntax | Effect |
| --- | --- |
| readonly / -readonly | add / remove readonly |
| ? / -? | add / remove optionality |
| as clause | key remapping; never drops the key |
| keyof T iteration | homomorphic; preserves modifiers |
| Record<K, V> | non-homomorphic; does not preserve |
Homomorphic mapped types ([K in keyof T]) preserve optionality and readonly from T and distribute over unions; hand-rolled Record<string, ...> does not. Pick intentionally.
4. Template literal types
Template literal types model string grammars at compile time. They are erased; runtime validation still owns correctness.
type Route = `/api/${"users" | "orders"}/${string}`;
type EventName<T extends string> = `on${Capitalize<T>}`;
type Props = { [K in "click" | "focus" as EventName<K>]: (e: Event) => void };
type ParseId<S extends string> =
S extends `${infer Prefix}_${infer Id}` ? { prefix: Prefix; id: Id } : never;
Practical uses: typed event maps, CSS unit strings, dot-paths for i18n keys, SQL table names. For real grammars (paths with parameters, template syntax), reach for a parser (for example a typed router library or Zod) rather than recursive string inference.
Known limits: no arithmetic, no guaranteed normalization, and error messages on failure are long. Depth limits exist for recursive conditional types (the compiler caps instantiation depth; exact cap is version-dependent — verify upstream).
5. Variance
TypeScript uses structural subtyping with variance inferred per type parameter; TS 4.7+ allows explicit annotations.
interface Producer<out T> { get(): T } // covariant, read-only
interface Consumer<in T> { accept(value: T): void } // contravariant
interface Box<in out T> { get(): T; set(v: T): void } // invariant
| Situation | Behavior |
| --- | --- |
| Function parameter with strictFunctionTypes | contravariant (correct) |
| Method syntax in an interface | bivariant (escape hatch, historical) |
| Arrays | covariant and unsound |
| Explicit in / out | checked, faster assignability, clearer errors |
Use out T on producers (stores, query results, context values) and in T on consumers (handlers, comparators). Do not annotate just to silence errors — if in/out disagrees, the design is wrong.
6. Branded and opaque types
Branded types make identifiers and units distinct at compile time at zero runtime cost.
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
function userId(raw: string): UserId {
if (!/^[0-9a-f-]{36}$/.test(raw)) throw new Error("invalid user id");
return raw as UserId; // one checked cast, at the boundary
}
Rules:
- Construct brands only through validating functions; never export the cast.
- Zod/Valibot can produce brands (
z.string().uuid().brand<"UserId">()), keeping parse and brand unified. - Keep brands shallow:
Brand<string, "Email">is enough; branding generics usually bills you in error messages. - Branded types serialize as their base type; re-parse on the way back in.
7. satisfies and const type parameters
const routes = {
home: "/",
user: (id: string) => `/users/${id}`,
} satisfies Record<string, string | ((id: string) => string)>;
// routes.user stays callable; routes.home stays "/"
const config = { mode: "dark", retries: 3 } as const satisfies Config;
| Tool | Effect | Use when |
| --- | --- | --- |
| : annotation | widens to the annotated type | you want exactly that API |
| as const | literals + readonly, no check | building tuple/literal data |
| satisfies | checks without widening | config objects, route maps, theme tokens |
| as | unchecked assertion | last resort, never on external data |
const type parameters (TS 5.0) apply the same idea inside generics:
function tuple<const T extends readonly unknown[]>(...items: T): T {
return items;
}
const t = tuple("a", 1); // readonly ["a", 1]
function defineRoutes<const T extends Record<string, string>>(r: T): T {
return r;
}
8. infer and controlled recursion
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type First<T extends readonly unknown[]> = T extends readonly [infer H, ...unknown[]] ? H : never;
type Last<T extends readonly unknown[]> = T extends readonly [...unknown[], infer L] ? L : never;
type Head<T extends string> = T extends `${infer H}${infer _Rest}` ? H : never;
Variadic tuple types let signatures capture fragments:
function curry<A extends unknown[], R>(fn: (...args: A) => R) {
return (...args: A): R => fn(...args);
}
declare function route<T extends readonly [string, ...unknown[]]>(path: T): T;
Type-level tests: readonly [infer H, ...unknown[]] requires a tuple; plain arrays return never for First. Always test both cases. Tail-recursive conditional types are optimized; non-tail recursion hits instantiation limits, so accumulate in an extra type parameter:
type Reverse<T extends unknown[], Acc extends unknown[] = []> =
T extends [infer H, ...infer Rest] ? Reverse<Rest, [H, ...Acc]> : Acc;
9. Type-level testing
Types are code; test them in CI. Three layers:
- Compile gate —
tsc --noEmit(ortsgo --noEmitfor the native compiler) oversrcandtest. - Assertions —
expect-typeor Vitest'sexpectTypeOf, co-located with unit tests. - Negative tests —
@ts-expect-erroronly where the line genuinely must fail; a bare// @ts-ignoreis banned by lint.
import { expectTypeOf, test } from "vitest";
test("UnwrapPromise", () => {
expectTypeOf<UnwrapPromise<Promise<number>>>().toEqualTypeOf<number>();
});
// Negative assertion: the next line must error, or the test file fails to compile
// @ts-expect-error -- UserId is not a plain string
const bad: UserId = "not-branded";
For public libraries, a dedicated *.test-d.ts file compiled with tsc -p tsconfig.test.json keeps consumer-facing types honest. tsd is an alternative but expect-type keeps one runner.
Limits of type tests: they check assignability, not soundness under any; they do not test runtime behavior. Pair every type assertion with at least one runtime path if the construct can fail at runtime.
10. Declaration patterns
// env.d.ts — augment a library's types
declare module "some-lib" {
interface Options { retry?: number }
export function configure(o: Options): void;
}
// globals.d.ts — augment the global scope
declare global {
interface Window { __APP_VERSION__: string }
var __BUILD_ID__: string;
}
export {}; // make this file a module so `declare global` is legal
// module.d.ts — ambient asset modules (Vite can generate these instead)
declare module "*.css" {
const styles: { [key: string]: string };
export default styles;
}
| Need | Pattern |
| --- | --- |
| Extend a library type | module augmentation in a project .d.ts file |
| Add a global | declare global inside a module file |
| Type an untyped package | declare module "pkg" shim + upstream typings PR |
| Strictly isolate types | --isolatedDeclarations plus explicit exported returns |
| Publish .d.ts only | declaration: true + emitDeclarationOnly |
Rules: never patch node_modules; keep ambient shims in one types/ folder; make skipLibCheck a conscious tradeoff (it hides broken dependency declarations — see 08-quality-tooling).
11. Type gymnastics: when not to
Compile-time power costs: slower checks, inscrutable errors, and knowledge only one person on the team has. Apply this test before writing >20 lines of type code:
- Does a runtime parser remove the need? (Usually yes at boundaries.)
- Would a small union or a code-generation step be simpler?
- Is the DX win worth the instantiation cost on every build?
- Can it live in a library you publish, not in application code?
If you cannot write a one-line doc comment describing the type, it is probably too clever.
Anti-patterns
| Anti-pattern | Why it hurts | Instead |
| --- | --- | --- |
| as unknown as T | erases evidence | parse and validate |
| Double conditional chains | unreadable errors | named step types + tests |
| Branding every primitive | error-message noise | brand identifiers and units only |
| enum for state | runtime object, poor tree-shaking, erasableSyntaxOnly conflict | literal union / as const |
| Record<string, any> | no key checking | Record<string, unknown> + parse |
| Recursive types beyond ~50 depth | instantiation explosion | flatten the model |
| Type tests without runtime tests | false confidence | pair both |
Checklist
- [ ] External values parsed, not cast.
- [ ]
satisfiesused for inferred config objects. - [ ] Unions discriminated and switched exhaustively with
assertNever. - [ ]
in/outvariance annotated on generic containers where intent matters. - [ ] Brands constructed through validators only.
- [ ] Public types covered by
expect-typetests and a compile gate. - [ ] No
ason external data; no@ts-ignore;@ts-expect-errorhas a reason. - [ ] Type-level code under ~20 lines per construct, documented with one line.
Related: 02-modules-build for declaration publishing, 06-backend-node for runtime validation at service boundaries, 08-quality-tooling for the compiler flags that make this machinery safe.
02-modules-build
Modules, Build, and Publishing
Scope: ESM/CJS interop,
module/moduleResolutionchoices, exports maps,verbatimModuleSyntax, aliases, transpilers versus type-checkers, library bundlers, dual publishing, and npm/JSR release practice.
Version floors (verify upstream; treat as minimums):
| Feature | Availability | Notes |
| --- | --- | --- |
| moduleResolution: bundler | TS 5.0+ | for bundler-owned graphs |
| module: preserve | TS 5.4+ | keeps import/require as written |
| require(esm) | Node 22.12+ / 23+ | unflagged; exact per-minor behavior varies |
| module-sync export condition | Node 22.10+ | sync require of ESM graph |
| TypeScript type stripping | Node 22.18+ / 24+ | run .ts directly for erasable syntax only |
| --erasableSyntaxOnly | TS 5.8+ | enforces type-strippable source |
| Trusted publishing (OIDC) | npm registry, current line | no long-lived tokens |
| JSR | stable | npm compat via npm: specifiers |
1. Pick module and moduleResolution together
These options are a pair; mismatches produce runtime ERR_MODULE_NOT_FOUND or phantom type resolution. The compiler cannot catch resolution that a bundler or runtime does differently.
| Target | module | moduleResolution | Consumer |
| --- | --- | --- | --- |
| Bundled browser app | ESNext or preserve | bundler | Vite, Rollup, webpack, esbuild |
| Node app, native ESM | nodenext | nodenext | Node "type": "module" |
| Node app, CJS legacy | node16/nodenext | same | Node without "type" |
| Library, broad compat | node16 (emitting) | node16 | both import and require |
| Deno | ESNext | bundler or Deno defaults | Deno 2 |
| Bun | ESNext | bundler | Bun |
Rules:
moduleResolution: node(node10) is legacy; do not start new work there.bundlerimpliesresolvePackageJsonExportsand friends; it forbids.jsextension-rewriting assumptions a bundler makes moot.- With
nodenext, relative imports need explicit extensions (.js,.mjs,.cjs) in the emitted graph. allowImportingTsExtensionsonly works withnoEmitorrewriteRelativeImportExtensions(TS 5.7+); otherwise the emitted graph is wrong.
2. Exports map anatomy
Modern resolution reads package.json, not the directory tree. main/types remain only as fallbacks for old resolvers.
{
"name": "acme-lib",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts", // must come first in each block
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"sideEffects": false,
"engines": { "node": ">=20" }
}
| Field | Purpose | Trap |
| --- | --- | --- |
| exports | encapsulation + conditions | omitting it lets deep imports in |
| types condition | declaration resolution | must precede import/require |
| imports (#alias) | internal subpath imports | needs matching exports for consumers |
| typesVersions | TS-version-specific types | avoid; support one modern TS |
| sideEffects | tree-shaking hint | false on a package with real side effects breaks apps |
| files | publish allowlist | dist built before npm pack |
Condition ordering matters: Node takes the first matching key. Put types first, then import, then require, then default. The official docs enumerate the full condition set (node, browser, development, production, module-sync); use the least number that solves the problem.
3. ESM/CJS interop
| From | To | Behavior |
| --- | --- | --- |
| ESM | CJS | default import = module.exports; named imports work when statically analyzable |
| CJS | ESM | require(esm) on current Node lines; otherwise dynamic import() only |
| Bundled ESM | CJS dependency | interop shims; test the built output |
| CJS | ESM with TLA | not allowed in require; use import() |
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const legacy = require("legacy-cjs-package"); // when you must, in ESM
// Dynamic import of either format
const mod = await import("dual-package");
__esModule interop is why esModuleInterop exists. In new code, prefer module: nodenext (which implies esModuleInterop) and write explicit default imports. Do not rely on export = types from a CJS dependency being transparent.
4. verbatimModuleSyntax and type-only imports
import type { User } from "./types.js"; // erased at emit
import { parseUser } from "./parse.js"; // kept
export type { User }; // re-export types explicitly
verbatimModuleSyntaxkeeps import/export syntax exactly as written instead of guessing; it makes each file's format explicit.- Requires
import type/export typefor types; otherwise you ship a runtime import of a type-only module. - Pairs with
isolatedModules(bundlers compile files independently) anderasableSyntaxOnly(Node type stripping). - Forbidden forms under these flags:
enum,namespacewith runtime members, parameter properties in constructors, andimport x = require(...).
5. Path aliases
paths are a compiler-only mapping. Runtimes and bundlers know nothing about them unless configured, so every alias needs a runtime counterpart.
// tsconfig.json — type-checking view
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }
| Environment | Runtime mapping |
| --- | --- |
| Vite | resolve.alias or vite-tsconfig-paths |
| Node | package imports (#lib/*) or a loader |
| Jest/Vitest | resolve.alias in test config |
| ts-node / tsx | tsconfig-paths loader |
Prefer Node subpath imports (import "#config" with an imports map) for server code: they resolve in Node, TypeScript, and most bundlers with one declaration. Avoid baseUrl entirely in new projects; use relative paths from tsconfig.json.
6. Transpilers: who type-checks
| Tool | Type-check | Speed | Use for |
| --- | --- | --- | --- |
| tsc | yes | slowest | libraries, CI gate, DTS |
| tsgo (native TS 7 preview) | yes | much faster | CI type gate, verify stability upstream |
| swc | no | very fast | Next.js, large transpile farms |
| esbuild | no | very fast | Vite deps, tsup, scripts |
| oxc | no | very fast | oxlint/oxfmt, emerging transforms |
Non-negotiable: a bundler or transpiler passing is not evidence of type safety. CI runs tsc --noEmit (or native equivalent) once over the whole workspace; with project references use tsc -b for incremental builds.
Gotchas when transpiling without checking: decorators need configured semantics (stage-3 versus legacy), const enum is not supported under isolatedModules (use as const objects), and type-only imports must be explicit.
7. Library build tools
| Tool | Engine | Formats | DTS | Choose when |
| --- | --- | --- | --- | --- |
| tsc | tsc | any | native | tiny libs, max compatibility, no bundling |
| tsdown | Rolldown | esm+cjs | yes (isolatedDeclarations-friendly) | modern default for packages |
| tsup | esbuild | esm+cjs | yes | mature, widely used |
| unbuild | rollup+esbuild | esm+cjs | yes | Nuxt ecosystem |
| Vite library mode | Rollup | esm (+cjs) | plugin | already in a Vite repo |
// package.json scripts for a tsdown-built package
{
"scripts": {
"build": "tsdown src/index.ts --format esm,cjs --dts",
"prepack": "npm run build",
"check:exports": "publint && attw --pack ."
}
}
Always run publint and @arethetypeswrong/cli (attw) before publishing: they catch missing types conditions, .d.ts format mismatches, and masquerading ESM/CJS. These checks are cheap and catch the most common packaging bugs.
8. Dual publishing
Dual publishing means shipping both ESM and CJS. Costs: two builds, the dual-package hazard (two module instances), conditional exports that must stay in sync, and .d.cts/.d.mts declaration pairs.
Decision table:
| Consumer base | Recommendation |
| --- | --- |
| Modern only (Node 20+, bundlers) | ESM only — simplest, shipping fewer files |
| Mixed, CJS consumers exist | Dual publish, test both entry points in CI |
| CJS-only tooling ecosystem | CJS only for that one adapter package; keep core ESM |
| Published utility with wide reach | Dual publish + publint/attw gates |
Test matrix for dual packages: node -e "require('./dist/index.cjs')", node --input-type=module -e "import('./dist/index.js')", plus attw --pack .. Add a TypeScript consumer smoke test that imports both entry points.
9. npm publishing
Checklist-grade practice:
npm pack --dry-runand inspect the file list before every first release.- Publish with provenance:
npm publish --provenance --access publicon a CI provider that supports OIDC, or configure trusted publishing so no token exists at all. filesallowlist plus.npmignoreonly as a fallback; never publishsrc, tests, or sourcemaps without reason (declaration maps are often worth keeping).- Enforce
enginesand document the supported Node range; run the test matrix on the floor version. - Use
changesetsor release-please for versioning; tag releases; keep a CHANGELOG generated from commits. npm audit signaturesverifies registry signatures of installed dependencies.
10. JSR publishing
JSR is the ESM/TypeScript-native registry used by Deno and consumable from npm via npm: / jsr: specifiers.
// jsr.json
{
"name": "@acme/lib",
"version": "1.2.0",
"exports": "./src/index.ts",
"publish": { "include": ["src/**/*.ts", "README.md", "LICENSE"] }
}
- JSR consumes TypeScript source directly; ship explicit types ("slow types" are rejected when they cannot be inferred without running the compiler).
deno publishruns the gate; from npm-only toolchains usenpx jsr publish.- Avoid Node built-ins in a JSR package published for Deno; use
node:specifiers if you must.
11. Migration notes: CJS to ESM
- Set
"type": "module"and rename CJS-only files to.cjs. - Replace
require/module.exports/__dirnamewithimport,export, andimport.meta.dirname(orfileURLToPath). - Convert
require()of JSON to import attributes (with { type: "json" }) where the runtime supports it, or read withfs. - Add explicit extensions in relative imports when targeting Node directly.
- Deal with TLA: only ESM supports it; make initialization explicit and ordered.
- Update tooling configs in the same PR; run the dual-entry smoke tests.
- Ship as a major version if the public package layout changes.
Common blockers: CJS-only dependencies (wrap in one adapter, or switch if maintained), Jest configs (Vitest or ts-jest ESM mode), and __dirname in config files.
Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| moduleResolution: node in new code | deep imports work locally, break in ESM | bundler or nodenext |
| No exports map | consumers deep-import internals | add exports, treat as API |
| types after import condition | "types show as any" reports | put types first |
| main only | CJS/ESM mismatch at runtime | explicit exports conditions |
| Path alias without runtime mapping | works in editor, fails at run | mirror in bundler/runtime |
| Building with esbuild and calling it done | type errors ship | separate tsc --noEmit gate |
| Publishing without publint/attw | broken types for subset of users | gate the release |
Checklist
- [ ]
module/moduleResolutionmatch the actual consumer (table above). - [ ]
exportsmap withtypesfirst;filesallowlist;sideEffectscorrect. - [ ]
verbatimModuleSyntaxon; all type-only imports explicit. - [ ] Type-check gate in CI distinct from build/transpile.
- [ ]
publintandattwpass for every published package. - [ ] Dual-package CI smoke tests when shipping both formats.
- [ ] Provenance or trusted publishing enabled; lockfile committed.
- [ ] Supported Node range documented and tested on the floor.
Related: 01-type-system for declaration patterns, 05-runtimes for how each runtime resolves modules, 08-quality-tooling for monorepo build graphs.
03-react
React
Scope: React 19+ application patterns — Server Components, hooks discipline, state ownership, data fetching, forms, performance, and testing with Vitest and Testing Library.
Version floors (verify upstream; treat as minimums):
| Feature | Availability | Notes |
| --- | --- | --- |
| React 19 | stable line since late 2024 | use, actions, ref-as-prop, metadata |
| useActionState, useOptimistic, useFormStatus | React 19 | form-centric APIs |
| useEffectEvent | React 19.x | stable on current minors; verify |
| <Activity> | React 19.x | pre-render/hide trees; verify |
| React Compiler | 1.0 in 2025 | opt-in build plugin; verify maturity |
| Server Components | framework-driven (Next.js, Remix/RR7, etc.) | not standalone React DOM |
| Testing Library + Vitest | current | @testing-library/react v16+ |
Treat "React 20" rumors as unconfirmed until released; pin the 19 line and verify upstream before adopting a new major.
1. React 19 baseline
What changed that matters for new code:
- Refs are regular props for function components;
forwardRefis no longer needed. PropTypesand string refs were removed; legacy class lifecycles are discourage-only.- Actions unify mutations: async functions passed to
<form action>orstartTransition, withuseActionStatefor result state anduseFormStatus/useOptimisticfor pending UI. use()reads promises and context; Suspense boundary placement decides UX.- Document metadata (
<title>,<meta>,<link>) can be rendered in components. refcleanup functions are supported; returning a function fromrefis the new cleanup contract.
2. Server Components and the client boundary
The server/client split is a build-time distinction, not a runtime one.
// app/users/[id]/page.tsx — Server Component by default (no directive)
import { db } from "@/server/db";
import { LikeButton } from "./like-button";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // Next.js 15-era async params
const user = await db.user.findUniqueOrThrow({ where: { id } });
return <main><h1>{user.name}</h1><LikeButton userId={user.id} /></main>;
}
"use client"; // marks this file and its imports as client-side
import { useOptimistic, useTransition } from "react";
export function LikeButton({ userId }: { userId: string }) {
const [pending, startTransition] = useTransition();
const [likes, addLike] = useOptimistic(0, (n: number) => n + 1);
return (
<button disabled={pending} onClick={() => startTransition(() => addLike(0))}>
Like ({likes})
</button>
);
}
Rules:
| Rule | Reason |
| --- | --- |
| "use client" at the top of the boundary file | everything it imports becomes client code |
| Server-to-client props must be serializable | functions (except Server Actions), classes, symbols fail |
| Client components cannot import server-only modules | use a server action or route handler instead |
| server-only package for DB/secret modules | build-time error instead of leaked secret |
| Do not pass Date/Map/Set across the boundary without a wire format | serialization rules are strict |
| Server Actions are public HTTP endpoints | authenticate and validate inside every action |
Server Actions:
"use server";
import { z } from "zod";
import { auth } from "@/server/auth";
const Input = z.object({ title: z.string().min(1).max(200) });
export async function createPost(formData: FormData) {
const session = await auth(); // authorize first
if (!session) throw new Error("unauthorized");
const input = Input.parse(Object.fromEntries(formData)); // validate second
await db.post.create({ data: { ...input, userId: session.userId } });
revalidatePath("/posts");
}
3. Hooks discipline
Rules of hooks are non-negotiable; ESLint's react-hooks rules are the enforcement. Beyond that, the senior decisions:
| Hook | Use for | Do not use for |
| --- | --- | --- |
| useState | independent local UI state | derived values, server data |
| useReducer | multi-field state with transitions | two booleans that form a union |
| useRef | DOM handles, mutable instance values | triggering renders |
| useEffect | synchronizing with external systems (subscriptions, imperative APIs) | data fetching, derived state, event logic |
| useLayoutEffect | measure-before-paint | anything that can run post-paint |
| useEffectEvent | stable callback reading latest props/state inside an effect | replacing the dependency array |
| useMemo/useCallback | proven hot paths or stable identities for memo children | everything "just in case" |
| useTransition | non-urgent state updates, pending UX | server mutations without local state |
| useDeferredValue | expensive derived renders on input | network debouncing |
| useSyncExternalStore | external stores, SSR-safe subscriptions | plain state |
Effect discipline: an effect either subscribes to something outside React or synchronizes to a browser API. If it computes UI from props, the state is derived — compute it in render instead.
function Search({ items, query }: { items: Item[]; query: string }) {
const filtered = items.filter((i) => i.name.includes(query)); // derived in render
return <List items={filtered} />;
}
4. State ownership decision
| State kind | Owner | Tooling |
| --- | --- | --- |
| One component's UI state | component | useState/useReducer |
| Shared by a subtree | nearest common parent or context | props first, then context |
| Server data cache | query library | TanStack Query |
| Cross-cutting client state | external store | Zustand, Jotai, Redux Toolkit |
| URL-addressable state | the URL | searchParams, router state |
| Form state | the form library | react-hook-form or native form actions |
Rules: do not mirror server data into Zustand; do not put state in context that changes once per keystroke; derive everything derivable.
Zustand 5 sketch:
import { create } from "zustand";
import { persist } from "zustand/middleware";
type CartState = {
items: Record<string, number>;
add: (sku: string) => void;
clear: () => void;
};
export const useCart = create<CartState>()(
persist(
(set) => ({
items: {},
add: (sku) => set((s) => ({ items: { ...s.items, [sku]: (s.items[sku] ?? 0) + 1 } })),
clear: () => set({ items: {} }),
}),
{ name: "cart" },
),
);
Always select narrowly: useCart((s) => s.items[sku]), never useCart() for the whole object in hot components.
5. Data fetching: TanStack Query v5 patterns
import { queryOptions, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export const userQueries = {
detail: (id: string) =>
queryOptions({
queryKey: ["user", id] as const,
queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
staleTime: 30_000,
}),
};
function UserCard({ id }: { id: string }) {
const { data, isPending, error } = useQuery(userQueries.detail(id));
if (isPending) return <Skeleton />;
if (error) return <ErrorBox error={error} />;
return <div>{data.name}</div>;
}
Practices:
- Query keys are tuples:
[entity, ...identifiers, params]; centralize factories per entity. staleTimeis the cache policy;gcTimeis memory retention. Defaults are conservative — set them deliberately.- Mutations own invalidation:
onSuccessinvalidates affected keys or sets exact data. useSuspenseQuerywhen the framework has Suspense boundaries; pair with error boundaries.- Prefetch on hover/route via
ensureQueryDatain loaders. - For RSC apps, read server-side with the same query client and hydrate; do not double-fetch.
6. Forms
Two supported styles; pick per surface.
Native actions + server validation:
"use client";
import { useActionState } from "react";
export function LoginForm({ action }: { action: (state: string | null, fd: FormData) => Promise<string | null> }) {
const [error, formAction, pending] = useActionState(action, null);
return (
<form action={formAction}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button disabled={pending}>Sign in</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
Client-heavy forms with react-hook-form + zod:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email(),
age: z.coerce.number().int().min(18),
});
type FormValues = z.infer<typeof Schema>;
export function ProfileForm() {
const { register, handleSubmit, formState } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: "", age: 18 },
});
return (
<form onSubmit={handleSubmit((values) => save(values))}>
<input {...register("email")} />
<input type="number" {...register("age")} />
<button disabled={formState.isSubmitting}>Save</button>
</form>
);
}
Rules: validate on both sides; server validation is authoritative (see 06-backend-node); disable submit while pending; show field errors in aria-live regions; never trust FormData shapes.
7. Performance
Order of interventions, cheapest first:
- Fix data waterfalls (parallel fetches, RSC streaming, loader prefetch).
- Reduce client boundary size (
"use client"as low as possible). - Split bundles with dynamic
import()and route-level code splitting. - Memoize identity only where a memo child or effect dependency requires it.
- Enable React Compiler where the toolchain is stable; verify output and bundle impact.
- Virtualize long lists; avoid layout thrash in effects.
- Measure: React DevTools Profiler,
<Profiler>, Core Web Vitals field data.
React Compiler notes: it eliminates most manual memo/useMemo when code follows the rules of hooks and has no mutation escapes. If the compiler is enabled, stop sprinkling manual memoization — mixed strategies confuse readers and can regress. Verify plugin maturity and build times upstream.
Common regressions: inline object/array props into memoized children, context value recreated every render, key={index} on reorderable lists, fetching in useEffect (waterfalls), and derived state stored in useState synced by effects.
8. Testing React with Vitest and Testing Library
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
test("submits the form", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<ProfileForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), "a@b.co");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "a@b.co", age: 18 });
});
Practices:
- Query by role, label, or text;
testidis a last resort. - Use
userEvent, notfireEvent, for interaction realism. - Wrap network with MSW; assert on UI, not implementation details.
- Prefer one integration-level render per behavior over mocked children.
- Test error and pending states explicitly.
- For RSC, test server components as functions returning elements and cover client components separately; end-to-end behavior belongs to Playwright.
Full strategy: 07-testing.
Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| Fetching in useEffect | waterfalls, race conditions | query library or RSC/loader |
| Derived state in useState + effect | stale flashes | compute in render |
| "use client" at the app root | whole app client-bundled | push boundary down |
| Context for fast-changing values | every consumer re-renders | store with selectors or lifted state |
| Prop drilling through RSC boundaries | non-serializable props | server action or client store |
| useMemo everywhere | noise, no measurable gain | profile first |
| key={index} on dynamic lists | wrong reconciliation | stable ids |
Checklist
- [ ] Server/client boundary explicit and as deep as possible.
- [ ] No secrets or DB imports reachable from client files (
server-onlyenforced). - [ ] Server Actions authenticate, validate, and authorize independently.
- [ ] Effects only synchronize with external systems; data fetching delegated.
- [ ] State owned at the right level; server cache not duplicated in client stores.
- [ ] Forms validate on client and server with one shared schema.
- [ ] Performance work driven by a profile, not by habit.
- [ ] Tests query by role/label and cover pending, error, and empty states.
Related: 04-frameworks for framework integration, 06-backend-node for action/route validation, 07-testing for the full test stack.
04-frameworks
Frameworks
Scope: current idiom for Next.js App Router, Vue 3.5+, Svelte 5 runes, Angular signals, Solid, and Astro, plus a selection table by project type.
Version floors (verify upstream; treat as minimums):
| Framework | Current line | Signal features |
| --- | --- | --- |
| Next.js | 15/16+ | async request APIs, App Router, cache directives, PPR |
| React Router | 7+ | framework mode, loaders/actions, typegen |
| TanStack Start | current | typed full-stack React, server functions |
| Vue | 3.5+ | reactive props destructure, useTemplateRef, vapor preview |
| Nuxt | 3.x/4.x | Nitro, server routes, hybrid rendering |
| Svelte | 5+ | runes, snippets, event props |
| SvelteKit | 2+ | load functions, form actions |
| Angular | 20+ | signals, zoneless, resource API, standalone |
| Solid | 1.9+ | signals, stores; SolidStart verify maturity |
| Astro | 5+ | islands, content layer, server islands, actions |
1. Selection by project type
| Project type | Default | Alternatives | Why | | --- | --- | --- | --- | | Marketing/content site | Astro | Next.js static, SvelteKit | minimal JS, content collections | | Full-stack React product | Next.js App Router | TanStack Start, React Router 7 | RSC, caching, actions, ecosystem | | React SPA behind separate API | Vite + React Router | TanStack Router | no SSR complexity | | Vue product | Nuxt | Vite + Vue Router | conventions, Nitro server | | Svelte product | SvelteKit | Vite + Svelte | runes + server primitives | | Enterprise Angular shop | Angular 20+ | Nx + Angular | DI, signals, long-term support | | Fine-grained reactive app | Solid | Svelte | smallest runtime updates | | Multi-runtime API | Hono | Fastify | see 06-backend-node |
Decision inputs that matter: team familiarity, hiring, SSR requirement, edge deployment, content authoring, and how much server logic the frontend framework should own. Framework choice is expensive to reverse; runtime and validation choices are not.
2. Next.js App Router
Mental model: every component is a Server Component unless marked otherwise; the router streams server-rendered UI over HTTP and hydrates only client islands.
Core practices:
- Treat
params,searchParams,cookies(), andheaders()as async on current majors;awaitthem. - Cache deliberately. Recent majors moved toward explicit caching:
"use cache"directives and cache components replace implicit fetch caching; verify the current flag names and defaults upstream. - Prefer Server Actions for mutations from forms; route handlers for public APIs and webhooks.
- Place
"use client"at the smallest interactive leaf; keep providers in a thin client wrapper. - Use
loading.tsx,error.tsx,not-found.tsxfor segment-level streaming and failures;Suspensefor finer control. - Route handlers are public; authenticate and validate like any HTTP endpoint.
- Middleware runs on every matched request; keep it to cheap checks (headers, redirects). Recent majors renamed/exposed it differently (proxy-style naming) — verify upstream.
- Metadata via the metadata API;
generateMetadatafor dynamic pages. next/imageandnext/fontremain the performance defaults; setimages.remotePatternsexplicitly.
// app/posts/actions.ts
"use server";
import { revalidateTag } from "next/cache";
import { z } from "zod";
export async function createPost(formData: FormData) {
const input = z.object({ title: z.string().min(1) }).parse(Object.fromEntries(formData));
await db.post.create({ data: input });
revalidateTag("posts");
}
Pitfalls: revalidatePath misuse (prefer tag-based invalidation), circular server/client imports, non-serializable props, layout state that survives client navigation unexpectedly, and buffering an entire page behind one slow fetch instead of streaming with Suspense.
3. React Router 7 and TanStack Start
Both target React full-stack without Next's opinionated caching.
- React Router 7 (framework mode): file routes,
loader/action, typed route params via generated types, SSR or SPA mode. Good when the team knows RR and wants less framework magic. - TanStack Start: typed server functions, TanStack Router search-param state, integrates with TanStack Query. Verify release maturity upstream before a long-lived bet.
Choose these over Next when you want route-level data loading without RSC, or you are already invested in TanStack tooling.
4. Vue 3.5+
Idioms:
<script setup lang="ts">withdefineProps/defineEmitsgenerics.defineModelfor two-way bindings; reactive props destructure is stable in 3.5.useTemplateRefreplaces template-ref string juggling when needed.shallowReffor large arrays/objects;readonlyfor exposed state.- Pinia for shared state; Nuxt auto-imports are convenient but keep explicit imports in libraries.
- Nuxt:
useAsyncData/useFetchwith keys, server routes underserver/api, Nitro presets per deployment target; hybrid rendering per route.
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{ items: { id: string; title: string }[] }>();
const query = ref("");
const visible = computed(() => props.items.filter((i) => i.title.includes(query.value)));
</script>
Pitfalls: destructuring a reactive object without toRefs (loses reactivity — 3.5 reactive props destructure softens this for props), watchers with side effects that should be computed, and storing large non-reactive objects in ref (use shallowRef).
5. Svelte 5 runes
Runes replace the legacy $:/export let/createEventDispatcher model.
| Rune | Purpose |
| --- | --- |
| $state | reactive state; plain objects are deeply reactive |
| $state.raw | replace-on-write state for large structures |
| $derived / $derived.by | computed values |
| $effect / $effect.pre | side effects tied to reactive reads |
| $props | component inputs; $props.id() for stable ids |
| $bindable | opt-in two-way binding props |
| $inspect | dev-only tracing |
SvelteKit: load functions for data, form actions for mutations, +page.server.ts for server-only logic, adapters per target.
<script lang="ts">
let count = $state(0);
const doubled = $derived(count * 2);
let { label = "Count", onchange = (n: number) => {} }: {
label?: string; onchange?: (n: number) => void;
} = $props();
</script>
<button onclick={() => { count += 1; onchange(count); }}>{label}: {doubled}</button>
Pitfalls: using $effect to compute state (use $derived), mutating props, mixing runes with legacy stores in new code, and event handlers using legacy on:click syntax.
6. Angular 20+ signals
Modern Angular is standalone components, signals, and DI via inject().
import { Component, computed, inject, signal, resource } from "@angular/core";
import { HttpClient } from "@angular/common/http";
@Component({
selector: "app-users",
template: `@if (users.isLoading()) { <p>Loading</p> } @for (u of users.value() ?? []; track u.id) { <p>{{ u.name }}</p> }`,
})
export class UsersComponent {
private http = inject(HttpClient);
readonly users = resource({ loader: () => fetch("/api/users").then((r) => r.json()) });
}
Practices:
signal,computed,effectfor local state;linkedSignalwhen one signal derives from another with local overrides.resource()for async reads with loading/error states; verify API stability per version.- Zoneless change detection is the direction of travel; remove
zone.jswhen the app is ready and verify per-major support. @if/@for/@switchcontrol flow replaces structural directives;trackis mandatory for loops.inject()over constructor injection in new code; keep DI tokens typed.- Forms: typed reactive forms for complex forms; signals integration is evolving — verify upstream.
Pitfalls: reading signals outside reactive contexts, effects that write other signals (use computed/linkedSignal), and mixing NgModule-era patterns into new standalone code.
7. Solid
Signals with fine-grained DOM updates and no virtual DOM.
import { createSignal, createMemo } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
return <button onClick={() => setCount((c) => c + 1)}>{doubled()}</button>;
}
Key differences: components run once (no re-render), so destructuring props breaks reactivity (props.x, never const { x } = props). Use <Show>, <For>, <Suspense>; stores for nested state. Pick Solid for render-heavy UIs where update cost dominates; verify SolidStart ecosystem maturity for full-stack needs.
8. Astro
Content-first: ships HTML, hydrates islands on demand.
- Island directives:
client:load,client:idle,client:visible,client:only="react". - Content Layer API (Astro 5+) unifies local and remote content in typed collections; use
getCollectionand schema validation. - Server islands defer costly dynamic fragments; actions handle form mutations.
- Adapters: Node, Vercel, Netlify, Cloudflare — pick by deployment, not by framework.
- View transitions for MPA-level navigation polish.
Pitfalls: hydrating entire pages with client:load, duplicating data fetching between collections and islands, and fighting Astro for SPA-style state — if the app is mostly interactive, choose a different framework.
9. Cross-framework concerns
| Concern | Guidance |
| --- | --- |
| Data fetching | framework loader/RSC first; query cache second; never useEffect |
| Mutations | server actions/functions where first-class; otherwise route handlers |
| Validation | one schema shared client and server (zod/valibot) |
| Styling | Tailwind, CSS modules, or framework-scoped styles; do not mix three systems |
| State | local first; framework store (Pinia/Zustand/NgRx Signals/Svelte runes) when shared |
| i18n | route-prefix strategy + typed key unions; avoid runtime string maps |
| Error handling | segment-level error boundaries (error.tsx, <svelte:boundary>, Angular ErrorHandler) |
Anti-patterns
| Anti-pattern | Framework | Fix |
| --- | --- | --- |
| "use client" on layout root | Next.js | move boundary to interactive leaves |
| Implicit caching assumptions | Next.js | declare cache behavior per fetch/tag |
| Destructured reactive props | Vue/Solid | toRefs / props.x access |
| $effect for derived values | Svelte | $derived |
| Writing signals inside effect | Angular | computed / linkedSignal |
| Hydrating everything | Astro | island directives by need |
| Framework lock-in in domain code | all | keep domain logic framework-free |
Checklist
- [ ] Framework chosen against the decision table, documented with reasons.
- [ ] Server/client (or island) boundaries deliberate and minimal.
- [ ] Data loading idiomatic to the framework; no effect-based fetching.
- [ ] Mutations authenticated and validated server-side.
- [ ] Domain logic in framework-agnostic modules with tests.
- [ ] Rendering mode (SSR/SSG/ISR/CSR) decided per route, not globally.
- [ ] Streaming/error boundaries configured for slow segments.
Related: 03-react for React depth, 05-runtimes for deployment targets, 06-backend-node for APIs behind these frontends.
05-runtimes
Runtimes
Scope: Node.js LTS lines, Bun 1.x, Deno 2.x, and edge runtimes — TypeScript support, API compatibility, and deployment constraints.
Version floors (verify upstream; treat as minimums):
| Runtime | Current in 2026 | TypeScript story |
| --- | --- | --- |
| Node.js 22 | maintenance LTS | type stripping on recent minors |
| Node.js 24 | active LTS | type stripping on by default |
| Node.js 26 | current release line | verify upstream |
| Bun | 1.2+ | native .ts/.tsx, no config |
| Deno | 2.x | native TS, JSR-first |
| Cloudflare Workers | workerd (continuous) | types via wrangler types; bundled TS |
| Vercel Edge / Netlify Edge | Web-API runtimes | bundled TS, subset of Node |
| Deno Deploy | Deno runtime | native TS |
Rule of thumb: pick the oldest supported LTS that satisfies constraints. Runtime novelty is a liability unless it buys something specific (startup time, permissions, edge latency).
1. Node.js LTS
Node 24 is the active LTS line through 2026; Node 22 stays in maintenance. New work should target Node 24 with an engines floor that matches the oldest deployment target your CI tests (typically >=22).
Capabilities that changed what TypeScript services look like:
| Capability | Availability | Notes |
| --- | --- | --- |
| fetch, Request/Response, FormData, Blob | stable | undici-based; AbortSignal everywhere |
| node:test runner | stable | node --test, snapshots, coverage flags |
| node --watch | stable | supersedes nodemon for simple cases |
| Type stripping of .ts | Node 22.18+/24+ on by default | erasable syntax only |
| --experimental-transform-types | current lines | enables enums/namespaces at runtime |
| require(esm) | Node 22.12+/23+ unflagged | unblocks some CJS consumers |
| WebSocket client | stable | no ws needed for clients |
| node:sqlite | experimental in 22, stabilizing | built-in SQLite; verify per minor |
| Permission model (--permission) | available, verify stability | read/write/net allowlists |
| node --run | stable | runs package.json scripts |
| Single Executable Applications (SEA) | available | for CLIs; verify tooling |
Type stripping constraints: only erasable syntax runs directly. enum, namespace with runtime values, parameter properties, and import x = require() fail unless transform mode is enabled. Enable erasableSyntaxOnly in tsconfig for code meant to run this way.
// package.json — run TS directly on Node 24
{
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"dev": "node --watch src/server.ts",
"start": "node src/server.ts",
"typecheck": "tsc --noEmit"
}
}
Deployment notes:
- Docker: multi-stage build,
node:24-slimor distroless, non-rootUSER,dumb-init/tinior signal-forwarding entrypoint,--max-old-space-sizetuned to the container limit,HEALTHCHECKon a/healthzroute. - Node as PID 1 does not forward signals to children unless configured; handle
SIGTERMin-process (see 06-backend-node). - Do not rely on
NODE_ENVsemantics beyond what libraries document; set it at build and run time explicitly.
2. Bun
Bun 1.2+ is a drop-in-oriented runtime, package manager, bundler, and test runner in one binary with native TypeScript/JSX execution.
What it is good at:
- Fast installs (
bun install), lockfile (bun.lock), and script running with far less startup latency than Node. - Built-in
bun test(Jest-compatible APIs),bun buildbundling, andbun --watch. - Built-in database clients (SQLite, Postgres) and Redis; S3 client.
- Native Node compatibility layer for most popular packages, including
node:builtins. - N-API support for native addons; verify per-addon.
Where to be careful:
- Node compatibility has sharp edges in exotic APIs (worker internals, some crypto, some streams); run your test suite under Bun before committing to it in production.
- Edge/serverless platforms do not run Bun as a managed runtime; use Bun mainly for servers you control, dev speed, and CLIs.
- Tooling ecosystem assumptions (some frameworks' adapters) may still target Node; verify.
// Bun server, native TS
const server = Bun.serve({
port: 3000,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/healthz") return new Response("ok");
return Response.json({ runtime: `bun ${Bun.version}` });
},
});
console.log(`listening on ${server.port}`);
3. Deno 2
Deno 2 keeps secure-by-default permissions and first-class TypeScript while adopting npm compatibility.
- Package management:
deno add npm:fastify/jsr:@std/http;deno.jsonholds imports, compiler options, tasks, lint/format config; lockfiledeno.lock. - Node compatibility:
node:specifiers andnpm:packages; most Node libraries work; frameworks like Hono and Fastify run with caveats. - JSR: native registry for TypeScript packages (see 02-modules-build).
- Tooling built in:
deno test(with sanitizers),deno lint,deno fmt,deno check,deno compilefor single binaries,deno bench. - Platform primitives: Deno KV, Queues, Cron, OpenTelemetry hooks, FFI.
- Permissions are granular:
--allow-net=api.example.com,--allow-read=./data,--allow-env=NODE_ENV.
// deno.json
{
"imports": { "@/": "./src/", "@std/": "jsr:@std/" },
"compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true },
"tasks": { "dev": "deno run --watch --allow-net src/server.ts" },
"exclude": ["dist"]
}
Deployment notes: Deno Deploy runs the same runtime at edge locations; locally you can compile to a binary or ship a Docker image based on the Deno runtime. Permissions should be enumerated in the start command, not dropped with -A.
4. Edge runtimes
Edge runtimes are V8 isolates with Web APIs and constrained I/O. They are not Node.
| Constraint | Cloudflare Workers | Deno Deploy | Vercel Edge |
| --- | --- | --- | --- |
| Filesystem | no | no | no |
| child_process | no | no | no |
| Node compat | nodejs_compat flag | strong (Deno) | partial |
| TCP sockets | via connect() | yes (per APIs) | restricted |
| Persistent DB | D1, KV, R2, Durable Objects, Hyperdrive | Deno KV, Postgres | vendor stores |
| CPU limits | plan-dependent, seconds | plan-dependent | plan-dependent |
| Memory | ~128 MB per isolate | plan-dependent | plan-dependent |
| Streaming | Web Streams | Web Streams | Web Streams |
Practices:
- Write handlers against Web APIs (
Request,Response,fetch,crypto.subtle) and keep vendor bindings at the edge of the code (adapter modules). - Bundle with the platform's toolchain (Wrangler, Vercel) so the
workerd/edge export conditions resolve; Node-targeted bundles will import fs or net and fail at deploy. - No long-lived in-memory state: isolates are evicted; use KV/Durable Objects for state and queues for background work.
- Cold starts reward small bundles: avoid mega-dependencies and top-level side effects.
- Local development: emulate with
wrangler dev(workerd) rather than trusting browser mocks; the emulator is the same engine.
Cloudflare specifics worth knowing: Durable Objects for coordination and per-key consistency, Hyperdrive for pooled Postgres, service bindings for worker-to-worker RPC, and Smart Placement for latency-sensitive fan-out. Verify current limits upstream; they change per plan.
5. Compatibility matrix
| API | Node 24 | Bun 1.2+ | Deno 2 | Edge |
| --- | --- | --- | --- | --- |
| fetch, Web Streams | yes | yes | yes | yes |
| node:fs | yes | yes | yes | no |
| node:http | yes | yes | most | partial via compat |
| node:crypto | yes | yes | most | WebCrypto only |
| Workers/threads | yes | yes | Web Workers | limited |
| AsyncLocalStorage | yes | yes | yes | via compat |
| process.env | yes | yes | via Deno.env / compat | bindings |
| Top-level await | ESM | yes | yes | yes |
| Native addons | yes | N-API | N-API, FFI | no |
| require(esm) | yes | yes | compat | n/a |
Portability rule: if a module must run in more than one runtime, isolate differences behind an interface, test on every target, and prefer Web APIs over node:* everywhere except unavoidable server internals.
6. TypeScript support per runtime
| Runtime | How TS executes | Caveats |
| --- | --- | --- |
| Node 24 | strip types, no transform | erasable syntax only; no path aliases |
| Bun | transpile-on-load | uses its own transpiler; tsconfig mostly respected |
| Deno | native | honors deno.json compilerOptions; JSR types |
| Workers/Vercel Edge | pre-bundled by platform | types come from wrangler types/generated env types |
| tsx / ts-node | loader-based | dev only; never production default |
Regardless of runtime, the type-check gate is tsc --noEmit (or native compiler) in CI. Runtime execution never substitutes for checking.
7. Anti-patterns
| Anti-pattern | Consequence | Fix |
| --- | --- | --- |
| Targeting latest current Node, deploying to older LTS | runtime syntax errors | engines + CI floor matrix |
| Relying on node: built-ins in edge code | deploy-time failures | Web APIs + adapters |
| bun install producing a lockfile other runtimes use | drift | one package manager per repo, lockfile committed |
| -A / --allow-all in Deno prod | no security boundary | enumerate permissions |
| Long tasks in edge isolates | CPU limit kills request | offload to queues/durable storage |
| ts-node in production | slow, fragile | precompile or native stripping |
| In-memory sessions on ephemeral runtimes | random logouts | external session store |
Checklist
- [ ] Runtime chosen per workload: Node for compatibility, Bun for throughput/dev, Deno for permissions/JSR, edge for latency.
- [ ]
enginesmatches the floor tested in CI. - [ ] TS runs natively only for erasable syntax;
erasableSyntaxOnlyset where relevant. - [ ] Edge code uses Web APIs only; vendor bindings isolated.
- [ ] Container: non-root, signal-aware, memory limit aligned with heap flags.
- [ ] Permissions explicit (Deno) or binding allowlists explicit (Workers).
- [ ] Lockfile committed for the chosen package manager.
Related: 02-modules-build for resolution and bundles, 06-backend-node for service patterns, 10-ecosystem-2026 for the current runtime landscape.
06-backend-node
Backend on Node and Friends
Scope: TypeScript service architecture — framework choice, validation, database layers, auth, queues, realtime, configuration, graceful shutdown, and observability.
Version floors (verify upstream; treat as minimums):
| Layer | Current line | Notes | | --- | --- | --- | | Fastify | 5+ | schema-first, plugin encapsulation | | Hono | 4+ | multi-runtime, typed RPC | | NestJS | 10/11+ | DI, decorators, enterprise structure | | zod | 4+ | Standard Schema, faster parsing | | valibot | 1+ | small bundle, tree-shakable schemas | | Prisma | 6/7 | typed client, migrations; verify driver/engine changes | | Drizzle | 0.4x/1.x line | SQL-first, typed, verify release channel | | Kysely | 0.28+ | query builder only, no runtime magic | | pino | 9/10+ | structured JSON logging | | OpenTelemetry JS | 2.x | traces/metrics/logs |
1. Framework choice
| Situation | Choose | Why | | --- | --- | --- | | HTTP API, single runtime, performance matters | Fastify | schema validation built in, mature plugins | | API on multiple runtimes (Node/Bun/Deno/edge) | Hono | Web-API core, tiny, typed client | | Large team, heavy DI, multiple transports | NestJS | modules/providers/guards, opinionated structure | | Legacy Express codebase | migrate incrementally | Express 5 improved but ecosystem momentum is elsewhere | | Internal RPC between TS services | tRPC or Hono RPC | end-to-end types without codegen | | GraphQL | GraphQL Yoga / Apollo | pick based on federation needs |
Do not choose a framework for benchmarks alone. Operational maturity (logging, validation, testing, plugin ecosystem) dominates.
2. Fastify patterns
import Fastify from "fastify";
import { z } from "zod";
const app = Fastify({ logger: true, requestIdHeader: "x-request-id" });
const CreateUser = z.object({ email: z.string().email(), name: z.string().min(1) });
app.post("/users", async (req, reply) => {
const input = CreateUser.parse(req.body); // validate at the edge
const user = await userService.create(input); // domain logic elsewhere
return reply.code(201).send(user);
});
app.get("/healthz", async () => ({ status: "ok" }));
app.get("/readyz", async () => ({ status: (await db.ping()) ? "ok" : "degraded" }));
await app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" });
Rules:
- One plugin per concern; use
fastify-pluginonly when you intend to break encapsulation. - Register JSON schemas or zod serializers/parsers so responses are validated too, not just requests.
- Use lifecycle hooks (
onRequestfor auth/rate limit,preHandlerfor authorization) instead of decorating handlers. - Return errors as RFC 7807-ish shapes consistently; set
setErrorHandleronce. - Fastify's schema compilation is a real performance feature; for hot routes prefer TypeBox/JSON Schema over ad-hoc validation.
3. Hono patterns
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const CreateTodo = z.object({ title: z.string().min(1) });
const app = new Hono<{ Bindings: { DB: D1Database } }>();
app.post("/todos", zValidator("json", CreateTodo), async (c) => {
const { title } = c.req.valid("json");
return c.json({ id: crypto.randomUUID(), title }, 201);
});
export default app;
- The app object is portable: run on Node via
@hono/node-server, on Bun, Deno, Workers. hono/clientgives an end-to-end typed client when the API is consumed from TypeScript.- Middleware is composable (
app.use(cors(), secureHeaders(), logger())); keep platform bindings behind typedBindings/Variablesgenerics.
4. NestJS notes
Use when the org benefits from enforced structure: modules, DI with @Injectable, providers, guards, interceptors, pipes.
- Pipes do validation (
ValidationPipe+ class-validator or a zod pipe); keep DTOs at the transport layer and map to domain objects. - Prefer constructor injection with explicit types; avoid request-scoped providers unless needed (they cascade cost).
- Use interceptors for cross-cutting concerns (logging, tracing), guards for auth, exception filters for error mapping.
- Test with
Test.createTestingModuleandsupertest; override providers rather than hitting real infrastructure.
5. Validation
Validation belongs at every boundary: HTTP bodies/params, env, queue messages, DB rows read raw, webhooks.
| Library | Strength | Tradeoff |
| --- | --- | --- |
| zod 4 | ecosystem, Standard Schema, great DX | bundle size in tight client budgets |
| valibot | small, tree-shakable | smaller ecosystem |
| TypeBox | JSON Schema-native, fastest for Fastify | verbose, JSON Schema ergonomics |
| arktype | type-first syntax | smaller ecosystem |
| Standard Schema | shared interface (~standard) | interoperability layer, not a validator |
import { z } from "zod";
export const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
});
export type Env = z.infer<typeof Env>;
Parse env once at startup and crash on failure. Never read process.env.X ad hoc deep in the codebase; pass a typed config object.
6. Database layer
| Need | Prisma | Drizzle | Kysely |
| --- | --- | --- | --- |
| Schema-as-code + migrations | strong | strong (drizzle-kit) | manual SQL migrations |
| Typed SQL-first queries | limited | good | excellent |
| Runtime weight | client engine | thin | thin |
| Complex SQL escape hatch | $queryRaw | .sql escape | native |
| Edge/serverless | driver adapters | yes | yes |
Rules that apply to all three:
- Transactions wrap invariants, not whole request handlers. Keep them short.
- Solve N+1 by construction: batch with
include/relational queries or explicitINqueries; log query counts in tests. - Migrations are forward-only in production; destructive changes go in expand/migrate/contract steps.
- Pool per process; serverless needs an external pooler (session vs transaction mode matters — verify the provider's guidance).
- Set statement and lock timeouts in the connection config; a stuck query should fail, not hold the request forever.
- Return domain objects from a mapping layer; do not leak ORM types across module boundaries.
7. Auth
| Need | Approach | | --- | --- | | Browser app, first-party | httpOnly, Secure, SameSite=Lax session cookie + server-side session store | | API for third parties | short-lived access token + rotating refresh token, or API keys with scopes | | Federated login | Auth.js / better-auth / OIDC provider; do not hand-roll | | Service-to-service | mTLS or signed tokens; never shared static secrets in env |
Non-negotiables:
- Password hashing: argon2id (preferred) or bcrypt with cost tuned to hardware; never SHA-family for passwords.
- Compare secrets with timing-safe comparison.
- Sessions: store server-side (DB/Redis), rotate on privilege change, expire idle and absolute.
- CSRF: SameSite cookies plus origin checks; token-based API clients exempt.
- Authorization happens server-side per resource — checks in the client are UX only.
- Never put secrets, tokens, or PII in JWT payloads (they are readable).
8. Queues and background work
| Tool | Fit |
| --- | --- |
| BullMQ | Redis-based jobs, retries, rate limits, schedulers |
| pg-boss | Postgres-only stacks, transactional enqueue |
| Cloud queues (SQS, Cloudflare Queues) | managed, at-least-once semantics |
| In-process setTimeout/worker threads | never for durable work |
Patterns: idempotency keys for every consumer; the transactional outbox when the job must reflect a DB commit; dead-letter queues with alerting; visibility timeout larger than worst-case handler time; and a separate worker process from the HTTP server.
9. Realtime
| Need | Use |
| --- | --- |
| Server push, one-way | SSE (simplest, HTTP-native, auto-reconnect) |
| Bidirectional, moderate scale | ws or framework WebSocket plugins |
| Rooms, fallbacks, presence | Socket.IO |
| Edge, per-room state | Durable Objects / KV-backed pub-sub |
Scale-out requires a shared bus (Redis pub/sub, NATS) or sticky routing; an in-memory room map breaks the moment you run two instances. Authenticate the upgrade request and apply per-connection rate limits.
10. Config
- One typed config module, parsed at startup, immutable in the process.
- Layers: defaults in code, overrides from env, secrets from a manager (Vault, AWS Secrets Manager, Doppler). Never commit
.envfiles; commit.env.examplethat stays in sync. - Feature flags are config, not code paths scattered through modules; load once, evaluate consistently.
- Distinguish build-time and runtime config: client-exposed prefixes are public (see 09-security).
11. Graceful shutdown
const shutdown = async (signal: string) => {
app.log.info({ signal }, "shutting down");
const timer = setTimeout(() => process.exit(1), 15_000); // hard deadline
try {
await app.close(); // stop accepting, finish in-flight
await db.destroy(); // close pools
await queue.close(); // stop workers
clearTimeout(timer);
process.exit(0);
} catch (err) {
app.log.error({ err }, "shutdown failed");
process.exit(1);
}
};
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
Rules: readiness must flip to failing before draining starts (Kubernetes preStop or a ready flag); the deadline must be shorter than the orchestrator's kill timeout; never call process.exit() mid-request; workers stop pulling new jobs before closing.
12. Observability
- Structured logs with pino: one JSON line per event, request id propagated via
AsyncLocalStorage, noconsole.login production code. - OpenTelemetry for traces/metrics; instrument HTTP, DB, and queue clients; export via OTLP to the platform collector.
- Correlation: accept/generate
traceparentandx-request-id; return the request id to clients for support. - RED metrics per route (rate, errors, duration) and saturation for pools; alert on symptoms (error budget burn), not causes.
- Health endpoints:
/healthzliveness (process alive),/readyzreadiness (dependencies checked), both cheap and unauthenticated. - Error reporting to Sentry or equivalent with release and user context, scrubbed of PII and secrets.
Anti-patterns
| Anti-pattern | Consequence | Fix |
| --- | --- | --- |
| Validation only on the client | malformed data reaches the DB | parse at every boundary |
| ORM entities across boundaries | coupling, accidental lazy loads | map to domain types |
| Per-request DB connection | pool exhaustion | shared pool per process |
| Long transactions | lock contention | short transactions around invariants |
| console.log | unstructured, unbounded | pino with levels |
| In-memory job state | lost on redeploy | durable queue |
| Auth middleware checking only authentication | horizontal privilege escalation | per-resource authorization |
| No SIGTERM handling | dropped requests on deploy | graceful shutdown |
Checklist
- [ ] Framework chosen per decision table; plugin/middleware organization explicit.
- [ ] All inputs parsed (HTTP, env, queue, webhook) with one validator family.
- [ ] DB access wrapped; transactions short; N+1 guarded by tests.
- [ ] Sessions/tokens stored and rotated per policy; argon2id hashing; per-resource authorization.
- [ ] Background work durable, idempotent, and dead-lettered.
- [ ] Typed config parsed at boot; secrets from a manager;
.envignored. - [ ] SIGTERM drains HTTP, DB, and workers within a deadline.
- [ ] Logs, traces, metrics, request ids, health endpoints in place.
- [ ] Realtime scale-out uses a shared bus.
Related: 05-runtimes for runtime constraints, 07-testing for service tests, 09-security for the security layer around all of this.
07-testing
Testing
Scope: Vitest, Testing Library, Playwright, mocking strategy, type tests, property-based testing, Testcontainers, coverage, CI sharding, and flake control.
Version floors (verify upstream; treat as minimums):
| Tool | Current line | Notes | | --- | --- | --- | | Vitest | 3/4+ | browser mode, projects, coverage v8/istanbul | | @testing-library/react | 16+ | React 19 support | | Playwright | 1.5x+ | sharding, trace viewer, component testing | | MSW | 2+ | fetch/XHR interception, service-worker mode | | fast-check | 3.22+/4+ | property-based testing | | Testcontainers for Node | 10/11+ | Docker-backed integration tests | | StrykerJS | 8/9+ | mutation testing | | expect-type | current | compile-time assertions |
1. Test layers and what belongs where
| Layer | Scope | Tool | Speed |
| --- | --- | --- | --- |
| Type tests | compile-time contracts | expect-type, tsc --noEmit | fast |
| Unit | pure functions, reducers, schemas | Vitest | fast |
| Component | rendered UI behavior | Vitest + Testing Library (jsdom or browser mode) | medium |
| Integration | service + real DB/broker | Vitest + Testcontainers | slow |
| Contract | client/server schema agreement | schema tests, Pact if multi-team | medium |
| E2E | user journeys through the real app | Playwright | slowest |
Rules: most tests are cheap; expensive tests cover only what cheap tests cannot. Every bug fix starts with a failing test at the cheapest layer that reproduces it.
2. Vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
coverage: { provider: "v8", reporter: ["text", "lcov"], thresholds: { lines: 80 } },
projects: [
{ test: { name: "unit", include: ["src/**/*.test.ts"], environment: "node" } },
{ test: { name: "dom", include: ["src/**/*.test.tsx"], environment: "jsdom" } },
],
restoreMocks: true,
unstubEnvs: true,
unstubGlobals: true,
},
});
Practices:
projects(formerly workspace file) splits node and DOM suites with different environments.- Prefer
vitest runin CI,vitestin watch mode; use--changedfor affected-only runs. - Browser mode (real Chromium/Firefox/WebKit) when jsdom lies: layout, focus, pointer events, CSS.
- Custom fixtures via
test.extendavoid repetitive setup and keep cleanup automatic. vi.useFakeTimers()for time-dependent code, but pair withvi.setSystemTimeand advance deliberately; never leave fake timers on for network waits.- Snapshot discipline: inline snapshots for small values,
toMatchFileSnapshotfor generated artifacts; update only in a reviewed commit.
import { test as base, expect } from "vitest";
type Fixtures = { user: { id: string } };
export const test = base.extend<Fixtures>({
user: async ({}, use) => {
const user = await createUser();
await use(user);
await deleteUser(user.id);
},
});
3. Component testing
- Query by role/label/text;
data-testidonly when no accessible query exists. - Interact with
userEvent(async, realistic) notfireEvent. - Assert what the user observes; avoid asserting internal state or hook calls.
- Cover four states per data view: loading, empty, error, success.
- Keep providers minimal: render with the same providers the app uses via a
renderApphelper. - Fake network with MSW, not by mocking
fetchin each test.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("/api/users/1", () => HttpResponse.json({ id: "1", name: "Ada" })),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
4. Mocking strategy
| Boundary | Mock with | Avoid |
| --- | --- | --- |
| Network | MSW, Playwright route | stubbing global fetch per test |
| Time/random | fake timers, seeded RNG | asserting on real clock |
| External SaaS | local fake server or interface fake | live calls in unit tests |
| Modules | inject dependencies; vi.mock only when unavoidable | mocking half the module graph |
| Database | Testcontainers or a repository fake | mocking the ORM chain |
Rules:
- Design for testability: pass dependencies in; a function that constructs its own client is hard to test.
vi.mockis hoisted and path-sensitive; keep mocks in one place (test/mocks/) and reset between tests.- Never mock what you own and can run (DB, queue): integration tests catch real failures.
- Assert on behavior (called-with, returned data), not on call order unless order is a requirement.
5. Playwright
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [["html"], ["junit", { outputFile: "reports/e2e.xml" }]],
use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["iPhone 15"] } },
],
});
Practices:
- Auth via
storageStateproduced by a setup project; never log in through the UI in every test. - Locators:
getByRole,getByLabel,getByText; CSS/XPath as a last resort. Useexpect(locator)auto-waiting assertions. - No
waitForTimeout; wait on UI state or network withexpect.poll/waitForResponse. - Network:
page.routefor deterministic responses and failure injection. - Shard in CI:
--shard=1/4across machines; merge reports. - Traces and videos on failure; artifacts uploaded per shard.
- Visual comparisons only for stable, reviewed surfaces; mask dynamic content.
6. Type testing
Three levels, all cheap enough to run on every PR:
tsc --noEmitover sources and tests.expect-typeassertions co-located with unit tests.@ts-expect-errornegative tests with an explanatory comment; a bare@ts-ignoreis banned.
import { expectTypeOf, test } from "vitest";
test("parseUser returns a branded id", () => {
const u = parseUser({ id: "1", name: "Ada" });
expectTypeOf(u.id).toEqualTypeOf<UserId>();
});
For libraries, compile a consumer-shaped file (tests/types/consumer.ts) against the built dist to catch packaging type regressions. Pair with publint/attw (see 02-modules-build).
7. Property-based testing with fast-check
Use when a function has algebraic properties (round-trips, idempotence, ordering) or a large input space.
import { fc, test } from "@fast-check/vitest";
const encode = (s: string) => Buffer.from(s, "utf8").toString("base64");
const decode = (s: string) => Buffer.from(s, "base64").toString("utf8");
test.prop([fc.string({ unit: "grapheme" })])("round-trips", (s) => {
return decode(encode(s)) === s;
});
Guidance: constrain arbitraries to the valid domain (dates within range, ids matching a grammar); cap numRuns for slow properties; keep shrinking on for readable counterexamples; log the seed on failure so CI flakes can be replayed locally. Property tests complement, not replace, example-based tests.
8. Testcontainers
import { PostgreSqlContainer } from "@testcontainers/postgresql";
const pg = await new PostgreSqlContainer("postgres:17-alpine")
.withDatabase("app_test")
.start();
process.env.DATABASE_URL = pg.getConnectionUri();
// run migrations once, then tests
afterAll(async () => { await pg.stop(); });
Rules: one container per suite (not per test) with a per-test transaction or truncation for isolation; reuse containers locally (withReuse()) to keep iteration fast; never rely on a shared remote test database; run migration tests against a real engine, because in-memory fakes hide SQL differences. In CI, Docker is usually available; if not, tag these tests and run them in a dedicated job rather than deleting them.
9. Coverage that does not rot
- Track coverage as a trend with a floor (for example 70-80% lines on business logic); do not chase 100%.
- Exclude generated code, config, and trivial re-exports deliberately.
- Prefer branch coverage on critical modules; per-file thresholds catch regressions the global number hides.
- Change-based coverage (only lines touched by the PR) is the strongest gate; full-repo thresholds punish teams for legacy code.
- Mutation testing (Stryker) on core domain modules exposes tests that execute code without asserting; run it nightly, not per-PR, on a scoped target.
10. CI and flake control
| Concern | Practice |
| --- | --- |
| Isolation | each suite owns its data; unique namespacing; no shared mutable global |
| Determinism | injected clock, seeded random, stable ids, fixed locale/timezone (TZ=UTC) |
| Parallelism | Vitest threads/pools sized to CI cores; Playwright workers per project |
| Sharding | split unit by project, e2e by --shard=i/n |
| Caching | cache package manager store and build artifacts; never cache test results |
| Retries | only for categories proven flaky; rewrite or quarantine instead of normalizing retries |
| Reporting | JUnit + HTML artifacts; annotate the PR with failures |
| Quarantine | failing flaky test gets an owner and expiry; not "disable forever" |
Flake protocol: reproduce locally with the recorded seed/trace, identify the nondeterminism (time, order, network, shared state), fix the root cause, add a regression guard. Retrying before diagnosis hides real bugs.
Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| Testing implementation details | refactor breaks green tests | test behavior via public API |
| waitForTimeout | slow, flaky e2e | wait on observable conditions |
| Snapshot as first choice | unreadable diffs, rubber-stamped updates | explicit assertions |
| Shared mutable DB state | order-dependent failures | isolation per test |
| Mocking the DB in integration tests | false confidence | Testcontainers |
| Covering everything at e2e | slow pipelines | push tests down the pyramid |
| Type tests omitted | any leaks ship | expect-type + compile gate |
Checklist
- [ ] Test layers mapped: type, unit, component, integration, e2e — each justified.
- [ ] Vitest projects split environments;
vitest runin CI with coverage thresholds. - [ ] Network mocked with MSW (unit/component) and route interception (e2e).
- [ ] Playwright auth via
storageState; traces on failure; sharded in CI. - [ ] Type tests cover public APIs and the built package.
- [ ] Property tests for algebraic logic, seeded and shrunk.
- [ ] Testcontainers for real DB/queue behavior; migrations executed in tests.
- [ ] Flake policy documented: quarantine with owner and expiry.
- [ ] CI deterministic: UTC, fixed clock, seeded RNG, isolated state.
Related: 03-react for component specifics, 06-backend-node for integration targets, 08-quality-tooling for wiring gates into CI.
08-quality-tooling
Quality and Tooling
Scope: tsconfig hardening flag by flag, ESLint 9 flat config, Biome/oxlint, Prettier, CI gate order, bundle budgets, and monorepo wiring.
Version floors (verify upstream; treat as minimums):
| Tool | Current line | Notes |
| --- | --- | --- |
| TypeScript | 5.x, native 7 line in preview | same semantics, faster binaries |
| ESLint | 9+ | flat config is the default format |
| typescript-eslint | 8+ | projectService for typed linting |
| Biome | 2+ | formatter + linter in one binary |
| oxlint | current | Rust lint, partial rule parity; oxfmt emerging |
| Prettier | 3+ | formatting reference |
| pnpm | 9/10+ | strict, content-addressed installs |
| Turborepo | 2+ | task graph + remote cache |
1. tsconfig hardening, flag by flag
Baseline: "strict": true. Everything below is a deliberate add-on; each has a cost.
| Flag | What it catches | Cost | Recommend |
| --- | --- | --- | --- |
| strict | the classic family (strictNullChecks, noImplicitAny, ...) | writing null checks | always |
| noUncheckedIndexedAccess | arr[i] may be undefined | more guards in loops/indexing | apps yes; libraries yes unless array-heavy perf code |
| exactOptionalPropertyTypes | { a: undefined } vs optional a | friction with optional props | yes for libraries; assess for apps |
| noImplicitOverride | missing override on subclass members | annotation noise | yes |
| noFallthroughCasesInSwitch | accidental case fallthrough | restructure switches | yes |
| noImplicitReturns | paths returning undefined implicitly | explicit returns | yes |
| noPropertyAccessFromIndexSignature | opts.foo on index signatures | opts["foo"] | optional, strict teams |
| useUnknownInCatchVariables | catch (e: any) | catch narrowing | yes (in strict since 4.4) |
| allowUnreachableCode: false | dead branches | none | yes |
| allowUnusedLabels: false | stray labels | none | yes |
| verbatimModuleSyntax | format ambiguity | import type discipline | yes |
| isolatedModules | unsafe single-file transforms | none with above | yes |
| erasableSyntaxOnly | enums/namespaces/param props | rewrite those constructs | yes if runtime strips types |
| isolatedDeclarations | implicit public types | explicit return types | libraries that need fast DTS |
| skipLibCheck | dependency .d.ts errors | hides broken deps | true in practice, revisit in CI matrix |
| forceConsistentCasingInFileNames | case-only import differences | none | yes |
| noErrorTruncation | truncated type errors | verbosity | useful when debugging types |
| noUnusedLocals / noUnusedParameters | dead code | noise without lint | pick one tool (lint or tsc), not both |
Order of adoption: strict -> noUncheckedIndexedAccess + noImplicitOverride + noFallthroughCasesInSwitch -> exactOptionalPropertyTypes -> isolatedDeclarations/erasableSyntaxOnly when the build or runtime requires them.
Sample app config:
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "preserve",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "tests"]
}
2. ESLint 9 flat config
Flat config is an array of typed objects; there is no cascade of .eslintrc files, no extends string resolution, and plugins are imported objects.
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
export default tseslint.config(
{ ignores: ["dist", "coverage", "**/*.generated.ts"] },
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
plugins: { "react-hooks": reactHooks },
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/consistent-type-imports": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
);
Rules for keeping lint useful:
- Typed linting (
recommendedTypeChecked+projectService) catches the bugs plain ESLint misses (no-floating-promises,await-thenable); accept the slower lint for that value. - Keep style out of ESLint: no formatting rules, no sorting debates. One formatter, one truth.
- Zero warnings in CI (
--max-warnings 0); suppress with a reason and an issue link, noteslint-disablestrings. - Lint generated code? Never. Ignore it explicitly.
- Migration from
.eslintrc: use the official codemod, then delete legacy files; flat config ignoreseslintIgnore, so re-express ignores in the config.
3. Biome and oxlint
| Dimension | ESLint + typescript-eslint | Biome 2 | oxlint | | --- | --- | --- | --- | | Language | Node/JS plugins | Rust | Rust | | Lint + format | separate (Prettier) | one binary | lint only (formatter maturing) | | Type-aware rules | yes, deep | partial (project domain) | limited | | Speed | slowest | fast | fastest | | Ecosystem | full plugin market | growing, conversion from ESLint config | partial parity | | Best fit | apps needing typed rules | greenfield, speed, single config | pre-commit speed layer |
Practical arrangements: ESLint typed rules as the correctness gate; Biome or oxlint as a fast local/CI complement; Prettier if the team standardizes on it, or Biome as both formatter and basic linter in smaller projects. Never run two formatters.
4. Prettier and formatting policy
- One formatter per repo, enforced in CI (
prettier --check .); editors format on save. - Keep the config tiny (
printWidth,semi,singleQuote, plugins). Formatting arguments end here. - Plugins worth having: Tailwind class sorting, import organization, framework plugins for
.svelte/.vue. - Generated files are ignored; lockfiles are never formatted.
- Formatting is a separate commit from logic changes, or a one-time repo-wide commit that does not mix with features.
5. CI gate order
Cheapest and most deterministic first; fail fast:
- Install with frozen lockfile (
pnpm install --frozen-lockfile/npm ci). - Format check.
- Lint (flat config, zero warnings).
- Type-check (
tsc --noEmitortsgo --noEmit), using incremental/project references. - Unit + component tests with coverage.
- Build (all packages; verify outputs).
- Integration tests (Testcontainers), then e2e (Playwright, sharded).
- Package checks for published libs (
publint,attw, size limits). - Bundle analysis on the app (budget check, not a block unless exceeded).
In monorepos, run affected-only for PRs (turbo/nx affected graph) but full on the default branch. Cache everything except test results.
6. Bundle analysis and budgets
- Analyzers:
rollup-plugin-visualizer(Vite/Rollup),source-map-explorer(any sourcemapped bundle), framework-specific inspectors. - Budgets:
size-limitin CI with per-entry limits; fail on regression beyond a threshold. - Tree-shaking hygiene:
"sideEffects": falseonly when true; avoid top-level side effects; import specific paths from large libraries; verify with the visualizer, not assumptions. - Code splitting: route-level by default; dynamic import for modals/editors/charts; preload likely next routes.
- Check both raw and gzip/brotli sizes; watch for duplicated dependency versions (pnpm
why/dedupe). - Server bundles matter too: cold start scales with imported code in serverless/edge.
7. Monorepo notes
| Concern | Practice |
| --- | --- |
| Package manager | pnpm workspaces (strict node_modules) or npm workspaces |
| Task running | Turborepo / Nx with declared inputs/outputs |
| TypeScript graph | project references + composite, tsc -b for incremental |
| Internal deps | workspace:* protocol; never file: links |
| Versioning | Changesets; one version policy per package |
| Publishing | publishConfig per package; shared build config package |
| Lint/format | one root config; packages extend |
| TS version | one version at the root, hoisted; no per-package drift |
Project references sketch:
// packages/core/tsconfig.json
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "dist" } }
// apps/web/tsconfig.json
{ "extends": "../../tsconfig.base.json", "references": [{ "path": "../../packages/core" }] }
Gotchas: composite requires declarations and rootDir discipline; path aliases must not bypass package boundaries (import @acme/core, not ../../core/src); and CI must rebuild when tsconfig changes, so include configs in task inputs.
Anti-patterns
| Anti-pattern | Symptom | Fix |
| --- | --- | --- |
| strict: false in new code | type system off | start strict, never relax |
| skipLibCheck masking own errors | hidden broken code | keep for deps, fix own .d.ts |
| Formatting via ESLint | rule conflicts, churn | one formatter |
| Typed lint disabled for speed | floating promises ship | keep typed rules on changed files |
| No bundle budget | slow creep | size-limit in CI |
| Per-package TS versions | incompatible types | single root version |
| Deep relative imports across packages | broken boundaries | package exports only |
Checklist
- [ ]
strictplus the hardening set chosen explicitly; every exception documented. - [ ] One formatter, enforced in CI; zero ESLint warnings.
- [ ] Typed linting enabled;
no-floating-promisesand friends are errors. - [ ] CI order: install -> format -> lint -> types -> tests -> build -> e2e -> package checks.
- [ ] Bundle budgets enforced for client and server entries.
- [ ] Monorepo: pnpm workspaces, turbo/nx affected graph, project references, one TS version.
- [ ] Published packages gated by
publintandattw.
Related: 02-modules-build for packaging gates, 07-testing for test wiring, 10-ecosystem-2026 for the tooling landscape.
09-security
Security
Scope: npm supply chain, XSS and Trusted Types, CSP, secrets, SSRF, Node runtime hardening, and dependency policy for TypeScript projects.
Version floors (verify upstream; treat as minimums):
| Control | Availability | Notes |
| --- | --- | --- |
| npm provenance | registry feature | npm publish --provenance |
| npm trusted publishing | registry feature | OIDC, no long-lived token |
| npm audit signatures | npm 8.7+ | registry signature verification |
| Trusted Types | Chromium/Edge | Firefox/Safari partial; feature-detect |
| Node permission model | Node 22/24 | --permission, verify stability |
| require(esm) | Node 22.12+/23+ | reduces risky interop shims |
1. npm supply chain
The npm ecosystem is the largest attack surface in a TypeScript project. Treat package installation as executing untrusted code.
Baseline controls:
- Commit one lockfile per repo and install with a frozen lockfile (
pnpm install --frozen-lockfile,npm ci). Never install without a lockfile in CI. - Disable lifecycle scripts for untrusted contexts:
npm ci --ignore-scripts(or pnpm'sonlyBuiltDependenciesallowlist). Re-enable per package only when needed and reviewed. - Publish with provenance and/or trusted publishing (OIDC) so consumers can verify where artifacts came from; never keep long-lived publish tokens in CI variables.
- Run
npm audit/pnpm auditandnpm audit signaturesin CI. Signatures verify registry transport, not package intent. - Use a minimum release age / cooldown for automated updates (for example 3-7 days) so compromised releases are caught by the community before you install them.
- Review lockfile diffs in PRs: unexpected transitive additions,
postinstallhooks, native gyp builds, and typosquats are the signals. - Prefer packages with provenance, maintained release cadence, few transitive deps, and repository verification. Popularity is not trust.
- For high-risk environments, install through a proxy/registry mirror that enforces policy (Verdaccio, Artifactory, Socket-style scanning) and pin allowlists.
Known attack patterns to recognize: typosquats (cross-env lookalikes), dependency confusion (internal names not scoped), maintainer account takeover (new maintainer + immediate patch release), protestware, obfuscated postinstall payloads, and transitive packages that fetch at install time.
2. Dependency policy
| Decision | Policy | | --- | --- | | Adding a dependency | check size, maintenance, last release, transitive count, license | | Update cadence | Renovate/Dependabot weekly with cooldown and grouped PRs | | Security updates | fast-track, test, ship within the SLA | | Major upgrades | scheduled, one per PR, with migration notes | | Abandoned packages | vendor, fork under an owned scope, or replace | | Internal packages | scoped names, registry allowlist, no typo surface | | Dev-only tools | still audited; they run with your credentials |
Track a software bill of materials (SBOM) for shipped artifacts where compliance requires it (npm sbom, CycloneDX tooling).
3. XSS and Trusted Types
Rules:
- React/Vue/Svelte escape interpolated content by default; the danger is bypass APIs:
dangerouslySetInnerHTML,v-html,{@html},innerHTML,insertAdjacentHTML,document.write. - Sanitize on the server when generating HTML, and again at render when the source is untrusted. Use a maintained sanitizer (DOMPurify) with an explicit allowlist of tags/attributes.
- Never interpolate URLs into
href/srcwithout scheme validation;javascript:anddata:payloads are XSS. - Prefer text content APIs for user data:
textContent, notinnerHTML. - Trusted Types enforce this at the platform level: set a CSP
require-trusted-types-for 'script'and create policies instead of strings. Feature-detect and keep sanitization as the fallback for engines without support.
const policy = window.trustedTypes?.createPolicy("app", {
createHTML: (dirty) => DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ["b", "i", "a"], ALLOWED_ATTR: ["href"] }),
});
el.innerHTML = policy ? policy.createHTML(userText) : DOMPurify.sanitize(userText);
Server-side rendering must apply the same sanitization; trusting "it comes from our API" fails when user content reached the API.
4. Content Security Policy
CSP is the second line of defense after correct escaping.
- Start with
Content-Security-Policy-Report-Onlyplus a report endpoint; promote to enforcing once reports are clean. - Prefer nonces or hashes over
unsafe-inline. Frameworks can emit per-request nonces for their bootstrap scripts. default-src 'self'; script-src 'self' 'nonce-<random>'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'is a reasonable baseline.- Add
strict-dynamicwhen using modern bundlers and nonces; keep a host allowlist fallback for older browsers. - Set
require-trusted-types-for 'script'andtrusted-typesto the policy names you actually use. - Keep the policy in middleware/headers so it applies to every response, including errors.
Other headers that belong with CSP: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy minimizing features, HSTS with a long max-age once HTTPS is guaranteed, and Cross-Origin-Opener-Policy/Cross-Origin-Resource-Policy where isolation is needed.
5. Secrets
- Secrets live in a manager (Vault, AWS/GCP secrets, Doppler) or platform secret stores; never in the repo, images, or client bundles.
- Client-visible prefixes (
NEXT_PUBLIC_,VITE_,PUBLIC_) are permanent disclosure. Document this and review any use. .env*files are git-ignored;.env.exampledocuments keys without values. CI has no.env.- Run secret scanning (gitleaks, TruffleHog) as a pre-commit hook and in CI, including history on first adoption.
- Rotate on any suspected exposure and on staff changes; prefer short-lived credentials (OIDC) over static cloud keys.
- Redact secrets in logs, error reports, and traces; configure the SDK scrubbers. Never log full request headers.
6. SSRF
Any server-side fetch of a user-controlled URL is an SSRF risk, including webhooks, image proxies, and URL previews.
Controls:
- Allowlist destination hosts (or public suffixes) rather than blocklisting
localhost/private ranges; parse the URL and compare the resolved host after DNS. - Resolve DNS yourself and connect to the validated IP, or use a proxy that enforces egress policy; this mitigates DNS rebinding.
- Block cloud metadata endpoints (
169.254.169.254, equivalent v6) and link-local ranges. - Enforce scheme (
https), port allowlists, redirect limits, timeouts, and response size caps. - Do not send credentials or internal headers to user-supplied destinations; strip auth on redirects.
- Prefer platform egress proxies (Cloudflare, AWS) when available; treat the app-level check as defense in depth.
const allowed = new Set(["api.stripe.com", "hooks.example.com"]);
const url = new URL(userInput);
if (url.protocol !== "https:" || !allowed.has(url.hostname)) throw new Error("blocked destination");
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
7. Node runtime hardening
| Risk | Control |
| --- | --- |
| Prototype pollution | validate/parse input; prefer Object.create(null) for maps; consider --disable-proto=throw; avoid recursive merge of untrusted objects |
| eval/new Function | banned by lint; no template-compiled user code |
| Command injection | execFile/spawn with argument arrays; never string-concatenated shell |
| Path traversal | resolve and verify the path stays under the intended root; reject .., absolute paths, symlink escapes |
| ReDoS | avoid unbounded regex on user input; prefer parsers or linear-time patterns; cap input length |
| JWT confusion | fix the algorithm allowlist; reject none; verify issuer/audience/expiry |
| Timing attacks | crypto.timingSafeEqual for token/key comparisons |
| Weak RNG | crypto.randomUUID/randomBytes, never Math.random for secrets |
| Unhandled rejections | fail fast on unhandledRejection in production processes |
| Excess privileges | run unprivileged; use the Node permission model (--permission --allow-...) or containers with read-only FS |
| Dependency native code | review gyp builds; prefer prebuilt, provenance-signed packages |
Cryptography defaults: use WebCrypto (crypto.subtle) or node:crypto primitives (AES-GCM, chacha20-poly1305, HKDF, Ed25519); never invent schemes; set TLS verification on (no rejectUnauthorized: false).
8. Web request security
- CSRF: SameSite cookies as the base case; add origin checks and per-session tokens for state-changing form posts. Server actions in frameworks need the same care.
- CORS: allowlist origins explicitly; never reflect arbitrary origins with credentials; keep preflight caching short.
- Cookies:
httpOnly,Secure,SameSite=Lax/Strict,Path=/, scopedDomain, and__Host-prefix where applicable. - Rate limit authentication, password reset, and expensive endpoints; return uniform errors to avoid user enumeration.
- Validate and normalize every request field per 06-backend-node; size-limit bodies and JSON depth.
- Log security-relevant events (login, permission change, key rotation) with actor and result, without secrets.
9. Secure defaults for new services
Apply these before writing feature code; retrofitting is where mistakes happen.
| Default | Setting |
| --- | --- |
| Error responses | generic message + request id; no stack traces, SQL, or paths |
| Source maps | uploaded to the error tracker, never served publicly |
| Debug endpoints | disabled in production; behind auth if they must exist |
| GraphQL | introspection off in production; depth and cost limits on |
| File uploads | type/size limits, store outside webroot, serve via signed URLs |
| Webhooks out | signed (HMAC) with replay protection; verify on ingress |
| Webhooks in | verify signatures before parsing business logic |
| Admin surfaces | separate auth tenant, IP allowlist where possible |
| Default credentials | none exist; first-run requires a setup token |
| Verbose banners | x-powered-by removed; version strings not exposed |
10. Incident response quick steps
- Contain: revoke tokens/keys, disable the affected endpoint, roll back the release.
- Preserve: snapshot logs, images, and the exact deployed lockfile before cleanup.
- Assess: determine data accessed, users affected, and whether credentials or PII were exposed.
- Eradicate: patch the root cause, rotate every credential the attacker could have read, and re-scan dependencies.
- Communicate: notify according to policy and regulation; keep a public status note if customers are affected.
- Learn: add a regression test and a CI gate for the class of issue, not just the instance.
Anti-patterns
| Anti-pattern | Risk | Fix |
| --- | --- | --- |
| dangerouslySetInnerHTML with user data | XSS | sanitize + Trusted Types |
| Secrets in NEXT_PUBLIC_/VITE_ env | permanent disclosure | server-only access |
| npm install without lockfile | supply-chain drift | frozen lockfile |
| --ignore-scripts only locally | inconsistent risk | allowlist built dependencies |
| Blocklist-based SSRF filter | bypass via DNS/redirects | allowlist + resolve + recheck |
| exec("cmd " + input) | command injection | execFile with args array |
| jwt.verify without algorithms | alg confusion | pin algorithms |
| Disabling TLS verification in prod code | MITM | fix certs |
Checklist
- [ ] Lockfile committed; CI installs frozen; lifecycle scripts allowlisted.
- [ ] Provenance/trusted publishing for packages you publish; audit + signatures in CI.
- [ ] Update cooldown enabled; lockfile diffs reviewed.
- [ ] All HTML injection points sanitized; Trusted Types where supported.
- [ ] CSP enforced with nonces/hashes; report-only rollout done first.
- [ ] Secrets in a manager; client prefixes audited; scanners in pre-commit and CI.
- [ ] Outbound fetches allowlisted, redirect-limited, timeout-bounded.
- [ ] Prototype pollution, injection, traversal, and ReDoS reviewed in code paths that touch input.
- [ ] Cookies httpOnly/Secure/SameSite; CSRF and CORS policies explicit.
- [ ] Runtime runs with least privilege; unhandled rejections crash loudly.
Related: 06-backend-node for request handling, 08-quality-tooling for enforcing these rules in CI, 02-modules-build for publishing safely.
10-ecosystem-2026
Ecosystem 2026
Scope: state of the TypeScript ecosystem in 2026, migration notes, recurring pitfalls, and the senior review checklist.
Everything here is a snapshot with a shelf life. Treat versions as floors, prefer stable APIs, and verify upstream before making a version claim in a deliverable.
1. TypeScript compiler line
| Track | Status | What it means |
| --- | --- | --- |
| TypeScript 5.x | stable line | all current language features; regular minors |
| TypeScript 6.x | bridge release | deprecations aligned for the native compiler; verify exact changes |
| TypeScript 7 (native, Go) | preview/early adoption | same type semantics, much faster; tooling API differences |
| tsgo binary | preview | drop-in type-check gate; watch editor/plugin support |
Practical stance for 2026: pin the newest stable 5.x/6.x for production, evaluate tsgo as a faster CI type gate, and do not adopt the native line in production until editors, lint tooling, and declaration emit are verified. Watch the deprecation list: legacy moduleResolution: node, older target values, and implicit-any-era defaults are being phased out. erasableSyntaxOnly and isolatedDeclarations are the flags that future-proof code for both the native compiler and runtime type stripping.
Ecosystem-wide TypeScript features that are now expected in senior code: satisfies, const type parameters, NoInfer, inferred type predicates, using for disposal, and import type under verbatimModuleSyntax. See 01-type-system.
2. Runtimes
- Node.js 24 is the active LTS through 2026; Node 22 maintenance; Node 26 current. Type stripping is default on current LTS lines for erasable syntax;
node:test, watch mode, fetch, and WebSocket are stable. Permission model andnode:sqliteare the areas to verify per minor. - Bun 1.2+ is production-viable for services you control: fast installs, native TS, built-in test/bundler, Postgres/SQLite/Redis clients. Verify Node-compat edges for heavyweight libraries.
- Deno 2 is npm-compatible and JSR-first, with granular permissions, built-in tooling, KV/Queues, and
deno compile. - Edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge) are the default for latency-sensitive read paths; treat them as Web-API-only and keep state in KV/Durable Objects/queues.
Details and matrices: 05-runtimes.
3. Frameworks and UI
| Framework | Line in 2026 | Center of gravity | | --- | --- | --- | | React | 19.x | Server Components, actions, compiler | | Next.js | 15/16+ | App Router, explicit caching, PPR | | Vue | 3.5+/3.6 | script setup, vapor mode in preview | | Svelte | 5+ | runes, snippets | | Angular | 20+ | standalone, signals, zoneless | | Solid | 1.9+ | fine-grained reactivity | | Astro | 5+ | islands, content layer |
The convergent trend: fine-grained reactivity (signals) and server-first rendering with explicit client islands. The framework-specific idioms are in 04-frameworks, React depth in 03-react.
4. Tooling landscape
| Category | Current | Notes | | --- | --- | --- | | Dev server / bundler | Vite 6/7, Rolldown-based Vite | Rust bundling for production builds | | App bundlers | Rollup, esbuild, webpack (legacy), Parcel (niche) | pick by plugin needs | | Library builds | tsdown, tsup, unbuild, tsc | see 02-modules-build | | Test runner | Vitest 3/4 | Jest compatibility layer for migration | | E2E | Playwright | traces, sharding, component testing | | Lint | ESLint 9 flat, Biome 2, oxlint | typed ESLint + fast Rust complement | | Format | Prettier 3, Biome | one per repo | | Package manager | pnpm 9/10, npm, Bun | strict installs recommended | | Task graph | Turborepo 2, Nx | affected-only CI | | Schemas | zod 4, valibot, TypeBox, arktype | Standard Schema interop |
Rules that survive tool churn: one formatter, one type-check gate, one package manager, one TS version, frozen lockfiles, and CI gates ordered cheapest-first. See 08-quality-tooling.
5. Data and services
- Validation is standardized around Standard Schema: zod/valibot/arktype implement it, frameworks accept it. Choose one per repo.
- ORMs split into schema-first (Prisma) and SQL-first (Drizzle, Kysely); the decision is about query complexity and deployment target, not popularity. See 06-backend-node.
- Serverless Postgres poolers (transaction mode) change ORM configuration; verify the provider's guidance before deploying.
- OpenTelemetry JS 2.x is the observability baseline; structured logging with pino is the default.
- Realtime: SSE for one-way, WebSockets/Socket.IO for two-way, Durable Objects for edge rooms.
6. Migrations
CJS to ESM
- Set
"type": "module"; rename true CJS files to.cjs. - Convert
require/module.exports; replace__dirname/__filename. - Add explicit extensions with
nodenext; or usebundlerand let the bundler resolve. - Convert JSON requires to import attributes or
fsreads. - Fix CJS-only dependencies via adapters, dynamic
import(), or replacement. - Run
attw/publinton the built package; smoke-test both entry points. - Major-version the release if the layout changes.
Jest to Vitest
Mechanical for 90% of suites: replace the runner, map jest.* to vi.*, convert config to vitest.config.ts, move setup files, and handle ESM-only deps (which Vitest supports natively). Keep the same assertions; migrate snapshot files with the codemod path. Watch fake timers and module mocking semantics.
Angular to signals
Standalone components first; then replace BehaviorSubject state with signal/computed, template reads with signal calls, RxJS kept at async boundaries (resource, toSignal). Remove zone.js once all async sources are signal-compatible; verify per-version support.
React 18 to 19
Remove forwardRef wrappers; PropTypes are gone; adopt actions and useActionState for forms; review use and Suspense boundaries; check third-party libs for React 19 peer ranges. Enable the compiler only after the rules-of-hooks lint is clean.
Next.js pages to App Router
Convert route by route; pages become server components; move data fetching into the component/loader; port getServerSideProps to async components; port API routes to route handlers or server actions; re-audit caching behavior since defaults changed across majors. See 03-react.
ESLint to flat config
Use the official migration tooling, replace extends/overrides with array composition, move ignores into config objects, and delete .eslintignore/.eslintrc*. Keep typed linting enabled for correctness rules.
7. Common pitfalls in 2026
| Pitfall | Why it bites | Guard |
| --- | --- | --- |
| Trusting bundler output as type-checked | esbuild/swc don't check | tsc --noEmit gate |
| Implicit framework caching | stale or missing data in prod | explicit cache directives/tags |
| Mixing module systems in one package | resolution errors for half the users | exports map + attw |
| Client bundles importing server modules | secret leakage, huge bundles | server-only, boundary lint |
| Runtime execution of non-erasable TS | enums fail under type stripping | erasableSyntaxOnly |
| Lockfiles ignored | supply chain drift | frozen installs |
| "Just add any" during migration | type debt compounds | unknown + parse |
| Adopting preview compilers in prod | tooling gaps | pin stable, test preview in CI |
| Time/randomness in tests | flakes | fake timers, seeded RNG |
| Missing graceful shutdown | dropped requests on deploy | SIGTERM handler + deadline |
8. Senior review checklist
Architecture and types:
- [ ] Domain logic framework-agnostic; boundaries (HTTP/DB/queue) thinnest possible.
- [ ] External data parsed at boundaries; no
any, no unchecked assertions on I/O. - [ ] Public APIs typed with documented generics; type tests present.
- [ ] Error model explicit: typed errors or Result at boundaries; no swallowed catches.
Modules and build:
- [ ]
module/moduleResolutionmatch consumers;exportsmap correct;verbatimModuleSyntaxon. - [ ] One package manager and lockfile; frozen installs in CI.
- [ ] Libraries pass
publint/attw; dual-package smoke tests where applicable.
Runtime and operations:
- [ ] Target runtime's constraints respected (edge/Node/Bun/Deno);
enginesmatches CI floor. - [ ] Config parsed once at boot; secrets from a manager; no client-visible secrets.
- [ ] SIGTERM drains HTTP, DB, queues within a deadline shorter than the orchestrator kill.
- [ ] Logs structured with request ids; traces/metrics exported; health/readiness endpoints live.
Quality:
- [ ]
strict+ hardening flags; typed lint; zero warnings; formatting enforced. - [ ] CI gates ordered: format, lint, types, tests, build, e2e, package checks.
- [ ] Bundle budgets enforced; no dependency added without size/maintenance review.
Security:
- [ ] Supply-chain controls (lockfile, cooldown, provenance, script allowlist).
- [ ] XSS surfaces sanitized; CSP enforced; Trusted Types where supported.
- [ ] SSRF allowlists, authz per resource, rate limits, cookie flags correct.
Testing:
- [ ] Test pyramid mapped; e2e limited to journeys; type tests in CI.
- [ ] Deterministic tests; flake quarantine with owner and expiry.
- [ ] Integration tests use real engines (Testcontainers) where semantics matter.
Versions and release:
- [ ] Version claims verified upstream; migration notes documented; changelog maintained.
- [ ] One TypeScript version per repo; preview tooling isolated from release paths.
9. Where to verify
- TypeScript release notes:
typescriptlang.org/docs/handbook/release-notes. - Node.js releases and LTS schedule:
nodejs.org/en/about/previous-releases. - Framework docs for the exact major you run: Next.js, Vue, Svelte, Angular, Solid, Astro.
- Package registries and provenance:
npmjs.com,jsr.io. - Web platform baselines: MDN and the W3C/WHATWG specs for browser APIs.
- Security advisories: GitHub Advisory Database, OSV.
Related: 01-type-system, 02-modules-build, 03-react, 05-runtimes, 06-backend-node, 07-testing, 08-quality-tooling, 09-security.