Playbook library
TypeScript backend service
TypeScript's guarantees stop at the process boundary, and generated backend code routinely acts as if they don't. These rules concentrate on the seam between the type system and reality: parsed input, handled rejections, bounded I/O, config that fails at boot rather than under load.
- Written for
- Node/TypeScript service teams — API servers, workers, jobs.
- Not covered
- No opinion here on framework choice, folder layout or test structure. Those are worth agreeing on, but they are team taste — these rules are about behaviour under failure.
11 rules6 blocking4 warning1 info
01 Parse external data, don't cast it
errorData crossing into the process — HTTP responses, queue payloads, `JSON.parse`, file contents — is parsed with a schema. `as`, `as unknown as`, `any` and `@ts-expect-error` without a linked issue are rejected at these boundaries. A cast is a compile-time claim about runtime data: the crash still happens, just later and in a place with no context.
Blocks: casting at the boundary defeats the reason the codebase is typed at all.
Scopesrc/**apps/**/src/**
Tagstypescriptvalidationcorrectness
02 No floating promises
errorEvery promise is awaited, returned, or explicitly discarded with `void` plus a `.catch` that logs. A bare async call as a statement is rejected. An unhandled rejection terminates the process on modern Node, so a fire-and-forget analytics call takes down the request handler that ignored it.
Blocks: the failure mode is process death under production load.
Scopesrc/**apps/**/src/**
Tagstypescriptasyncreliability
03 Every outbound call has a timeout
errorHTTP client calls, database statements, and lock acquisitions pass an explicit timeout or `AbortSignal`. Node sets no default socket timeout, so a single hung dependency holds connections and event-loop work until the pool is exhausted and the whole service is down — not just the feature that called it.
Blocks: a missing timeout converts a partner's slowdown into your outage.
Scopesrc/**apps/**/src/**
Tagsreliabilityhttpresilience
04 Read config once, at startup
errorEnvironment variables are read and parsed in the config module and imported from there. `process.env.X` anywhere else in application code is rejected. Config parsed at boot fails the deploy; config read lazily fails on whichever request first reaches that branch, possibly weeks later.
Blocks: it decides whether a misconfiguration is caught by the deploy or by a customer.
Scopesrc/**apps/**/src/**
Tagsconfigreliability
05 Background handlers are idempotent
errorA queue or cron handler tolerates running twice on the same payload — guard with a unique constraint, an idempotency key, or an upsert. At-least-once delivery is the norm for every broker worth using, so a retry that charges a card twice is a scheduled incident, not an edge case.
Blocks: duplicate side effects on money or messaging are customer-visible and hard to unwind.
Scopesrc/**/jobs/**src/**/workers/**src/**/*worker*src/**/*job*
Tagsreliabilityqueueidempotency
06 Money is integers or decimals, never floats
errorMonetary amounts are integer minor units or a decimal type, all the way through. `parseFloat`, `number` arithmetic on prices, and float columns are rejected. Binary floating point cannot represent 0.1, so totals drift by cents and the drift lands in a customer invoice.
Blocks: financial errors are unrecoverable in the sense that matters — trust.
Scopesrc/**
Tagscorrectnessbilling
07 Throw typed errors with stable codes
warningThrow an error class from the shared errors module carrying a stable `code`. `throw new Error("failed")` and throwing a string are rejected. Callers cannot branch on prose, so they end up matching message text — and then a copy-edit changes behaviour in production.
Warns: it degrades maintainability rather than breaking the current code path.
Scopesrc/**apps/**/src/**
Tagserrorsmaintainability
08 Log through the logger, with a correlation id
warningApplication code logs via the shared structured logger, passing an object that includes the request or job id. `console.log` and `console.error` outside of scripts and bootstrap are rejected. Unstructured lines cannot be filtered, correlated or sampled, so they cost money to store and give nothing back during an incident.
Warns: real operational cost, no functional risk.
Scopesrc/**apps/**/src/**
Tagsobservabilitylogging
09 Time is UTC instants
warningStore, pass and compare timezone-aware UTC values; convert to a local timezone only at the presentation edge. Deriving business logic from a bare `YYYY-MM-DD` string, or from the server's local timezone, is rejected — it works until a deploy region changes or the clocks shift.
Warns: bugs appear twice a year and at region boundaries, not on the day of the merge.
Scopesrc/**
Tagscorrectnesstime
10 New dependencies arrive pinned and scoped
warningA package added to the manifest is pinned to an exact version or a bounded range, lands in `devDependencies` when only tooling uses it, and is not a second copy of something else the same change adds — two HTTP clients, two date libraries, two validation libraries. Agents reach for a package by reflex; every added dependency is permanent supply-chain surface and someone's future upgrade.
Warns: sometimes the dependency is right — the point is that adding one becomes a decision rather than a reflex.
Scopepackage.json**/package.json
Tagssupply-chaindependencies
11 Exported functions declare their return type
infoFunctions exported from a module or a package annotate their return type instead of relying on inference. An inferred return type means an internal refactor can silently change a public contract, and the error surfaces in a consumer's build rather than in this PR.
Informational: it improves review signal; nothing breaks today.
Scopepackages/**/src/**src/**/index.ts
Tagstypescriptapi-design
Before you import all of it
Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.
Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.
Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.
The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.