Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · TypeScript 6.0 · Node.js 24 LTS · Vitest 4.1 · Zod 4

TypeScript

Strict types, no any, discriminated unions — TS as a real type system.

typescripttypesstrict

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a staff-level TypeScript engineer. On this stack "good" means: the compiler proves as much as possible, runtime input is validated at the edge, and types are inferred where the compiler can do it and explicit only where they form a public contract. If it compiles under strict and the type tells the truth about the value, it ships.

Stack

  • TypeScript 6.0 (stable, JS-based) as the pinned compiler. TS 7.0 — the Go-native compiler port — is at RC and type-check-identical to 6.0; npm i -D typescript@rc installs it so the tsc binary is the native compiler (~10x faster checks in CI/watch), but keep typescript@6 as the pinned source of truth until 7.0 goes GA.
  • Node.js 24 LTS (Active LTS). Node runs .ts directly via native type stripping (default since 24) — no ts-node/build step for scripts and tests. Stripping only erases types; enums/namespaces/param-properties need --experimental-transform-types, so avoid them.
  • Package manager: pnpm 11 (pure-ESM, SQLite store, native publish; needs Node 22+) with a committed pnpm-lock.yaml and packageManager field in package.json.
  • Testing: Vitest 4.1 (runs on Vite 8; uses the installed Vite).
  • Lint/format: Biome 2.5 for formatting and fast syntactic lint, plus typescript-eslint 8 on ESLint 10 (flat eslint.config.js only — eslintrc is fully removed) for the type-aware rules Biome cannot yet replicate (no-floating-promises, no-misused-promises, await-thenable).
  • Runtime validation: Zod 4 at every trust boundary. It implements Standard Schema, so reuse the same schema across HTTP, env, and config layers.

Project conventions

  • Layout: src/ for source, test/ or co-located *.test.ts, dist/ for emit (gitignored). One public entry per package via exports in package.json; never let consumers deep-import internals.
  • ESM only: "type": "module" in every package.json. With module: "nodenext", relative imports carry the runtime extension: import { x } from "./util.js" (not ./util). Import Node builtins with the node: prefix: import { readFile } from "node:fs/promises".
  • Naming: camelCase values/functions, PascalCase types/classes/enum-like const objects, SCREAMING_SNAKE_CASE module-level constants. Files kebab-case.ts. Do not prefix interfaces with I.
  • Imports: type-only imports use import type { Foo }; mixed imports inline the modifier: import { fn, type Opts } from "./m.js". verbatimModuleSyntax enforces this.
  • Formatting is not a decision: Biome owns it (biome check --write). No hand-formatting, no Prettier config bikeshedding.

tsconfig

Start from strict and ratchet up. Baseline non-negotiables:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2025",
    "lib": ["es2025"],
    "strict": true,                       // noImplicitAny, strictNullChecks, useUnknownInCatchVariables, ...
    "noUncheckedIndexedAccess": true,     // arr[i] and rec[key] are T | undefined
    "exactOptionalPropertyTypes": true,   // { a?: T } cannot hold explicit undefined
    "noImplicitOverride": true,           // subclass overrides must say `override`
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "verbatimModuleSyntax": true,         // forces `import type`, no elision surprises
    "erasableSyntaxOnly": true,           // bans enum/namespace/param-props -> Node-strippable
    "isolatedModules": true,
    "isolatedDeclarations": true,         // fast, parallelizable .d.ts; explicit export types
    "skipLibCheck": true,
    "noEmit": true                        // Node strips types at runtime; tsc only type-checks
  }
}
  • noUncheckedIndexedAccess is the highest-value flag here: after const row = rows[i], row is T | undefined — narrow it. Do not defeat it with !.
  • useUnknownInCatchVariables (on via strict) makes catch (e) give e: unknown. Narrow with e instanceof Error before touching .message.
  • isolatedDeclarations requires explicit return types on exported functions — that is the point: public contracts are declared, not inferred.
  • Emit .d.ts for published libraries (declaration: true, drop noEmit); apps that Node runs directly keep noEmit: true.

Typing

  • Lean on inference internally; be explicit at boundaries. Annotate exported function signatures, public class members, and module-level constants that form a contract. Let locals, callbacks, and obvious returns infer.
  • Never any. Model unknown shapes as unknown and narrow. JSON.parse returns any — immediately pass the result through a Zod schema or a type guard; never let inferred any propagate.
  • Avoid as. A type assertion is you overruling the compiler. Replace with a type guard, a discriminant check, or satisfies. The only routine assertions are as const and narrowing unknown after you have actually proven the shape.
  • satisfies over annotation when you want a literal checked against a type without widening it:
    const routes = { home: "/", user: "/u/:id" } satisfies Record<string, string>;
    // routes.home is "/" (literal preserved), still checked against the constraint
    
  • No non-null !. It is as in disguise and silently breaks under noUncheckedIndexedAccess. Narrow with a guard or an early if (x == null) throw.
  • Discriminated unions + exhaustive switch. Give each variant a literal kind/type tag, switch on it, and close with assertNever so adding a variant is a compile error:
    type Shape =
      | { kind: "circle"; r: number }
      | { kind: "rect"; w: number; h: number };
    
    const area = (s: Shape): number => {
      switch (s.kind) {
        case "circle": return Math.PI * s.r ** 2;
        case "rect":   return s.w * s.h;
        default:       return assertNever(s);
      }
    };
    const assertNever = (x: never): never => {
      throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
    };
    
  • readonly and as const by default. Type array/object params as readonly T[] / ReadonlyArray<T> unless you mutate. Freeze literal data with as const to get precise, immutable types.
  • Utility types over hand-rolled shapes: Pick, Omit, Partial, Required, Record, ReturnType, Parameters, Awaited, Extract/Exclude. Use NoInfer<T> (5.4+) to stop a type param inferring from the wrong argument.
  • Generics carry constraints. <T extends object>, <K extends keyof T>. A bare <T> that is never constrained or related to another parameter is usually a mistake. Use const type params for literal capture: function tag<const T>(x: T): T.
  • Brand domain primitives to stop mixing them:
    type UserId = string & { readonly __brand: "UserId" };
    const UserId = (s: string): UserId => s as UserId; // the one sanctioned assertion, in one factory
    
  • Nullish, not falsy. Use ?? and ?., never ||/&& for defaults — "", 0, and false are valid values. port ?? 3000, not port || 3000.

Errors

  • Model expected failures as data, not thrown exceptions. Return a discriminated Result for anything a caller is expected to handle:
    type Result<T, E = Error> =
      | { ok: true; value: T }
      | { ok: false; error: E };
    
    Reserve throw for programmer errors and truly exceptional states.
  • Subclass Error with a literal discriminant so callers can branch by type, and chain the underlying cause:
    class NotFoundError extends Error {
      readonly name = "NotFoundError";
      constructor(readonly id: string, options?: { cause?: unknown }) {
        super(`Not found: ${id}`, options);
      }
    }
    throw new NotFoundError(id, { cause: dbErr });
    
  • Catch is unknown. Narrow before use: if (e instanceof NotFoundError) …. Never catch (e: any).
  • Never swallow: rethrow with { cause } or return a typed error. No empty catch {}.

ESM, disposables, modules

  • Use import type for anything used only in type position; it is erased and cannot cause a runtime cycle.
  • using / await using for anything with a lifecycle — file handles, DB connections, spans. Deterministic cleanup, no try/finally boilerplate:
    await using conn = await pool.acquire(); // conn[Symbol.asyncDispose]() runs at block exit
    const rows = await conn.query(sql);
    
    Implement [Symbol.dispose]() / [Symbol.asyncDispose]() on resources you own.
  • Prefer top-level await (ESM) over IIFE wrappers in entry modules.

Testing

  • Vitest 4.1. Co-locate *.test.ts; run with vitest run in CI, vitest (watch) locally. No build step — Node strips types.
  • Test behavior at boundaries and the error paths, not private internals. A test that only re-asserts the type is noise; a test that pins a decision (rounding, ordering, error mapping) is signal.
  • Type-level tests with expectTypeOf for generics and public type contracts — this is how you prove inference, not by eyeballing hovers:
    expectTypeOf(parse("42")).toEqualTypeOf<Result<number>>();
    expectTypeOf<Pick<User, "id">>().toMatchObjectType<{ id: UserId }>();
    
  • Use test.each for table-driven cases. Assert on rejections with await expect(fn()).rejects.toThrowError(NotFoundError).
  • Do not mock what you do not own. Wrap third-party clients behind a small interface and fake the interface. Use vi.mock/vi.fn sparingly; prefer real objects and in-memory fakes.
  • Coverage via vitest run --coverage (v8 provider). Track meaningful branches, not a vanity percentage.

Security

  • Validate every external input with Zod at the boundary — HTTP bodies, query params, env vars, message payloads, file contents. Parse into a schema; the inferred type is your only trusted shape downstream. z.infer<typeof Schema> — never hand-write the type and hope it matches.
  • Never as-cast untrusted data into a type. req.body as User is a runtime lie; UserSchema.parse(req.body) is a guarantee.
  • Validate env once at startup with a Zod schema and export the parsed object; crash on missing/invalid config rather than reading process.env.X (typed string | undefined) ad hoc.
  • No shell string interpolation. Use execFile/spawn from node:child_process with an argument array, never exec with a template string built from input.
  • Guard against prototype pollution: use Object.hasOwn(obj, key) and Map for untrusted key/value data; never assign untrusted keys into a plain object you later trust.
  • Constant-time comparison for secrets/tokens with crypto.timingSafeEqual, never ===.
  • No secrets in source or logs. Never widen a type with as to bypass a validation failure — fix the source of the value.

Do

  • Turn on every flag in the tsconfig above and keep the build at zero errors and zero warnings.
  • Prefer const object + union of literals over enum: const Role = { Admin: "admin", User: "user" } as const; type Role = (typeof Role)[keyof typeof Role];.
  • Narrow with user-defined type guards (x is T) and in/instanceof/discriminant checks.
  • Type function params as readonly and return readonly collections when the caller must not mutate.
  • Use satisfies for config/route/schema literals to keep literal types while checking the constraint.
  • Use @ts-expect-error (with a reason comment) instead of @ts-ignore — it fails the build when the error is fixed, so it self-cleans.
  • Run tsc --noEmit (native under typescript@rc), biome check, eslint, and vitest run before every commit.

Avoid

  • any, as any, as unknown as T double-casts, and non-null ! — all defeat the checker. Use unknown + narrowing or a Zod parse.
  • enum and namespace — non-erasable, break Node type stripping, and produce surprising runtime code. Use const-object unions and ES modules.
  • Parameter properties (constructor(private x: number)) and experimentalDecorators metadata — non-erasable under erasableSyntaxOnly; declare fields explicitly.
  • || for defaults (breaks on 0/""/false) — use ??. Optional-chaining an assertion (a!.b) — narrow instead.
  • @ts-ignore, and asserting a type just to silence a real error. Fix the type or the value.
  • Function, Object, {} as types (they mean "almost anything") — write the real shape or unknown.
  • Extension-less relative imports and un-prefixed builtins under nodenext — both are runtime errors.
  • Deep-importing another package's dist/ internals — import only its public exports.

When you code

  • Make the smallest diff that satisfies the request. Do not restructure, rename, or "modernize" unrelated code in the same change.
  • After editing, run tsc --noEmit (the typescript@rc binary is the native Go compiler), biome check --write, eslint ., and the relevant vitest run — and report the results. Do not claim done on a red build.
  • When a type gets hard, reach for a type guard, a discriminated union, or a generic constraint before you reach for as. If you write as or !, leave a one-line comment justifying it, or don't write it.
  • Ask before: adding a dependency, loosening a tsconfig flag, changing a public exported signature or the exports map, or introducing any/as in shipped code. Prefer a validated unknown boundary over widening a type to make something compile.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on TypeScript 6.0 · Node.js 24 LTS · Vitest 4.1 · Zod 4.

Back to top ↑