Safety, performance, and developer experience — in that order. Every rule in this document exists to serve one of those goals; style is not taste. These rules make invalid states unrepresentable, move failures to compile time or to system boundaries, and make the failures that remain loud. They are language-canonical: no framework, runtime, or platform assumptions. Follow them when writing or editing code in this repository.
We operate a zero-technical-debt policy: do it right the first time, because the second time may not transpire, and a problem solved in design is many times cheaper than one solved in production. When a rule conflicts with existing code, prefer the rule and refactor the old code when touched.
Always say why. When a rule, a decision, or a piece of code needs a rationale, write the rationale — in this document, in comments, in commit messages. A stated reason lets the reader evaluate the decision, not just obey it.
- No
any. Useunknownand narrow. - No type assertions (
as T,<T>x) on data you don't fully control. Assertions are permitted only inside a validated constructor or type guard. - No
enum. Use string-literal unions +as const. - No
Partial<T>on function inputs. Use explicitPick<>types. - Every value crossing a boundary (network, file, env, user input,
JSON.parse) is parsed and validated before use — never cast and trust. - Immutability by default:
readonlyproperties,ReadonlyArray<T>,as const. Mutation must be local, centralized, and justified (§11). - Functions are total where possible: every declared input type produces a declared output — absence and failure are part of the return type.
- Two error channels, never mixed: expected operating failures are values
(
Result); programmer errors are assertions that crash (§9). - Put a limit on everything. Every loop, queue, retry, and collection has a stated upper bound (§10).
- Assert invariants. Preconditions, postconditions, and invariants are checked at runtime; core logic averages at least two assertions per function (§9).
eslint.config.mjs is the source of truth; a copy here would drift from it.
It holds, at minimum: no-explicit-any, the no-unsafe-* family, a ban on
type-assertion syntax outside validated constructors, prefer-readonly,
switch-exhaustiveness-check, curly: all, and max-lines-per-function: 70.
The rules below that stock ESLint cannot express live in the local plugin,
scripts/eslintPlugin/.
Formatting: Prettier with printWidth: 100. 100 columns is a hard limit,
without exception — nothing may hide behind a horizontal scrollbar. Use the
full width; never go beyond.
These rules are checked by machines wherever a machine can check them, and
the same checks run for people and for every coding agent (Claude Code, Codex,
Pi). docs/enforcement.md maps each rule to the check that holds it and lists
the rules that remain for review.
- Agent hooks format and check each file you edit, and check every changed file before you finish. Fix what they report; do not work around them.
npm run check:changedruns the finish check by hand. Run it before you say you are done.- Git hooks check staged files, commit messages, and pushes. Never skip them
(
--no-verify); the agent hooks refuse it, and CI runs the same checks. - The files that define the gates (this file, lint and compiler configs,
scripts/{eslintPlugin,policy,agent}/, hook configs,shared/core/limits.ts) change only with a human's approval.
Model state as a union where data exists only in the states that have it — never parallel optional fields and booleans.
export type LoadState<T> =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: T };
export function renderState<T>(s: LoadState<T>, render: (d: T) => string): string {
switch (s.kind) {
case "idle": return "";
case "loading": return "…";
case "error": return s.message; // Only exists in this variant.
case "ready": return render(s.data);
default: {
const _exhaustive: never = s; // Adding a variant = compile error here.
return _exhaustive;
}
}
}Prefer this over enum — unions narrow properly, erase at runtime, and
interoperate with plain data:
export type Status = "draft" | "pending" | "approved" | "rejected";A value is checked once at the boundary; after that, its type proves validity. Hand-rolled guards are the canonical form (see §17 before reaching for a schema library — one is justified only for genuinely large shapes):
export interface Project {
readonly id: number;
readonly title: string;
readonly status: Status;
}
const STATUSES = ["draft", "pending", "approved", "rejected"] as const;
export function parseProject(input: unknown): Project {
if (typeof input !== "object" || input === null) {
throw new Error("Project: expected object");
}
const p = input as Record<string, unknown>;
if (!Number.isSafeInteger(p.id)) throw new Error("Project.id: expected safe integer");
if (typeof p.title !== "string" || p.title.length === 0) {
throw new Error("Project.title: expected non-empty string");
}
if (p.title.length > LIMITS.titleBytesMax) {
throw new Error("Project.title: exceeds limit"); // Bounds are checked at the boundary.
}
if (!STATUSES.includes(p.status as Status)) {
throw new Error("Project.status: unknown value");
}
return { id: p.id, title: p.title, status: p.status as Status };
}
// Usage — the ONLY place a cast-like operation happens:
const project = parseProject(JSON.parse(raw) as unknown);Rules:
JSON.parse(x)is immediately bound tounknown, then validated.- A predicate
x is Tis acceptable for narrowing, but aparseXthat throws (or returnsResult) is preferred — it produces a trustworthy value instead of merely claiming one. - Parsers enforce the bounds from §10: max string lengths, max array sizes, integer safety. The boundary is where limits are cheapest to check.
Expected failures are values. Programmer errors crash (§9). Never mix the two
channels: do not catch an assertion failure and convert it to a Result, and
do not return err for a violated invariant.
export type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E };
export interface AppError {
readonly code: string; // Stable machine code.
readonly message: string;
}
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });Which channel:
| Situation | Channel |
|---|---|
| Network down, disk full, invalid user input, not found | Result — the caller must handle it |
| Broken invariant, impossible state, violated precondition | Assertion — crash loudly, fix the code |
Callers handle both Result branches (use §1's exhaustiveness). catch binds
unknown — narrow before use or wrap with String(e). All errors are
handled: most catastrophic production failures come from incorrect handling of
errors that were explicitly signaled, not from the errors themselves.
declare const brand: unique symbol;
export type Brand<T, B> = T & { readonly [brand]: B };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
export const toUserId = (s: string): UserId => {
if (s.length === 0) throw new Error("UserId must be non-empty");
return s as UserId; // Assertion allowed ONLY here, after validation.
};
function getOrder(id: OrderId): void { /* ... */ }
// getOrder(userId) is a compile error, not a production bug.export const LOCALES = ["en-us", "fr-fr", "de-de"] as const;
export type Locale = typeof LOCALES[number]; // Add to the array → type updates.
export const LIMITS = {
pageSize: 20,
maxRetries: 3,
queueDepth: 1024,
titleBytesMax: 256,
} as const satisfies Record<string, number>;
// `satisfies` checks the shape without widening literals away.
// Every bound in the system lives here or next to the code that enforces it.- Everything the function can do must be visible in the signature: failure →
Resultor union return; absence →T | undefined; never a silentnullwhenundefinedis meant (pickundefinedas the canonical "absent"). - No boolean parameters — they hide meaning at the call site:
// Bad: fetchUsers(true, false)
// Good:
export function fetchUsers(opts: {
readonly includeInactive?: boolean;
readonly bypassCache?: boolean;
}): Promise<Result<readonly User[]>> { /* ... */ }- Options objects use
readonlyproperties and optional-with-defaults; avoid required-fields-after-optional (use a single options parameter). - Pass correctness-relevant options explicitly at the call site. Never rely silently on a library's default for anything that matters — defaults change between versions, and the diff won't show at your call site.
- Callbacks go last in the parameter list. This mirrors control flow: callbacks are also invoked last.
- Type predicates for filters:
const defined = <T>(x: T | undefined): x is T => x !== undefined; - Keep signatures low-dimensional. Each added branch in a return type is
viral — it propagates handling cost up the whole call chain.
voidbeatsbooleanbeatsTbeatsT | undefinedbeatsResult<T>; use the simplest type that is still honest.
export interface Config {
readonly name: string;
readonly tags: readonly string[];
}- Function parameters and returns are
readonly/ReadonlyArrayby default. - Update by construction (
{ ...prev, status: "approved" }), not mutation. - If a class mutates internal state, that state is
privateand never exposed by reference. - Local mutation is fine when it is centralized. The function that owns the
control flow may own local mutable state (§11); leaf helpers stay pure — they
take values and return values. Don't confuse "immutable API boundaries" with
"no local
letanywhere".
- Constrain generics (
<T extends { id: string }>); an unconstrainedTthat is only stored and returned is fine, but never use generics to launder types. - No function overloads unless a union of signatures genuinely can't express it — prefer union parameter/return types or generics with conditional types.
- Template literal types for structured strings:
type Route = \/${string}/v${number}`` — validation at compile time.
Types prove structure; assertions prove the invariants types can't express. A function must not operate blindly on data it has not checked — assertions downgrade catastrophic correctness bugs into loud, findable crashes.
export function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(`Assertion failed: ${message}`);
}Because of asserts condition, each assertion also narrows for the compiler —
runtime check and type information from one statement.
Rules:
- Assert preconditions, postconditions, and invariants. Core logic averages a minimum of two assertions per function. Boundary parsers already validate input, so internal assertions target internal invariants — the assumptions your own code makes.
- Pair assertions. For every property you enforce, find at least two code paths that check it: assert before writing to disk and after reading back; assert at the producer and the consumer.
- Assert the positive space you expect AND the negative space you don't. Interesting bugs live exactly where data crosses the valid/invalid boundary — which is also why tests must exercise invalid data, and valid data as it becomes invalid.
- Split compound assertions.
assert(a); assert(b);overassert(a && b);— simpler to read, and the failure message tells you which condition broke. - Use a blatantly true assertion instead of a comment where the condition is critical and surprising — documentation the machine enforces.
- Assertions are a safety net, not a substitute for understanding: build the mental model first, encode it as assertions, then let tests and fuzzing hunt for what you and your reviewer both missed.
In reality, everything has a limit; code that doesn't state its limits will find them in production, as a tail-latency spike or an OOM. Violations must fail fast, near their cause.
- Every loop has a provable upper bound.
while (true)is reserved for event loops, and those must assert progress or an explicit iteration cap. - Queues, retries, batches, pages, and caches are capped — the constants
live in
LIMITS(§5), enforcement lives at the boundary or the point of accumulation. Hit the limit → backpressure or fail fast, never silent growth. - Parsers enforce maximum lengths on strings and arrays (§2).
- Prefer iteration to recursion. Recursion needs a justified, asserted depth bound; an unbounded stack is an unbounded loop by another name.
There is a sharp discontinuity between a function that fits on a screen and one you must scroll through. Art is born of constraints.
- Hard limit: 70 lines per function (enforced by lint). When splitting,
don't chop arbitrarily — divide by responsibility:
- Centralize control flow. Push
ifs up andfors down: the parent function keeps theswitch/ifbranching and owns the state; helpers contain non-branchy logic and stay pure. All control flow is handled by one function; the rest don't care about control flow at all. - Good function shape is often the inverse of an hourglass: few parameters, a simple return type, meaty logic between the braces.
- Centralize control flow. Push
- Simple, explicit control flow. Split compound boolean conditions into
nested
if/elsebranches; split longelse ifchains into trees so each case is visibly handled. Compound conditions make it impossible for the reader to verify that all cases are covered. - State invariants positively. Negations are hard to reason about:
// Easy to get right — matches how index and count are normally compared:
if (index < count) {
// The invariant holds.
} else {
// The invariant doesn't hold — handle or assert.
}
// Harder, and against the grain: if (index >= count) { ... }- For every
if, consider the matchingelse— handle the negative space or assert it away (§9). - Always use braces, even for one-line bodies — defense against "goto fail;"-class bugs.
- Smallest possible scope. Declare variables where they are first used, never before they are needed; minimize the number of variables in scope; calculate and check values close to their use. Bugs live in the gap between place-of-check and place-of-use.
Great names capture what a thing is or does and show that you understand the domain. Take time to find nouns and verbs that work together.
- No abbreviations.
sourceandtarget, notsrcanddest— same-length related names have the second-order effect that derived variables (sourceOffset,targetOffset) line up in calculations. Exception: primitive loop counters. Use long-form flags in scripts:--force, not-f. - Units and qualifiers go last, sorted by descending significance, so the
name starts with the most significant word:
latencyMsMax, notmaxLatencyMs. ThenlatencyMsMinlines up beside it, and everything latency-related groups together. - Nouns over adjectives and participles.
replica.pipeline, notreplica.preparing— a noun can be used directly in documentation, conversation, and derived identifiers (config.pipelineMax). - One name, one meaning. Never overload a term with context-dependent meanings — if two domains collide, rename one.
- Acronyms keep their capitalization:
HTTPRequest,parseURL. - Helpers are prefixed by their caller when a function delegates:
readSectorandreadSectorCallbackshow the call history in the name. - TypeScript conventions apply throughout:
camelCasefor values and functions,PascalCasefor types and classes.
Code alone is not documentation.
- Comments are sentences: a space after
//, a capital letter, and a full stop (or a colon when introducing what follows). They are well-written prose, not scribblings in the margin. End-of-line comments may be unpunctuated phrases. - Say why. Explain the rationale for non-obvious decisions; share the criteria a future reader needs to evaluate the code, not just to read it.
- Say how. Every test file opens with a comment block describing the goal and methodology of the test — enough for a reader to get oriented, or to skip sections, without reverse-engineering the intent.
- Prefer an assertion to a comment when the statement is a checkable invariant (§9).
The usual suspects for off-by-one errors are casual interactions between an
index, a count, and a size. They are all number, but they are not the same
thing: indexes are 0-based, counts are 1-based, sizes carry a unit. Make the
distinction compile-time real:
export type Index = Brand<number, "Index">; // 0-based position.
export type Count = Brand<number, "Count">; // 1-based quantity.
export type SizeBytes = Brand<number, "SizeBytes">; // Quantity × unit.- Conversions are explicit and named: index → count adds one; count → size
multiplies by the unit. Units appear in variable names (
timeoutMs,sizeBytes, §12). - Show division intent.
Math.floor,Math.ceil,Math.trunc— never a bare/when the result feeds an index or a count. The reader must see that you thought through the rounding. numberis a float. CheckNumber.isSafeIntegerat boundaries; beyond 2⁵³, usebigintand say so in the type.
The best time to get the 1000× wins is the design phase, precisely when you can't profile. Work with the grain.
- Back-of-the-envelope sketches over the four resources (network, disk, memory, CPU) and their two characteristics (bandwidth, latency). Sketches are cheap; be roughly right before you build.
- Optimize the slowest resource first (network → disk → memory → CPU), weighted by frequency of use — a cache miss repeated often enough costs as much as an fsync.
- Batch. Amortize network, disk, and CPU costs by batching accesses; drain queues at your program's own pace instead of reacting to every external event. Batching keeps control flow under your control and bounds work per time period.
- Allocation in hot paths is a design decision, not an accident. Hoist, reuse, and preallocate where the profile or the sketch says it matters.
- Separate control plane from data plane. Batching the data plane lets you keep high assertion density in the control plane without losing throughput.
- Extract hot loops into standalone functions with primitive arguments — a human reader (and the JIT) spots redundant work more easily.
A file is read top-down on first read, so put important things near the top.
- Public API and entry points first; internal helpers below.
- Complex nested types become top-level declarations.
- When no better order exists, sort alphabetically — big-endian naming (§12) makes related items adjacent.
Dependencies bring supply-chain risk, safety and performance unknowns, and slow installs; their cost is amplified through everything built on them.
- Standard library and platform first. Every new runtime dependency needs a written justification in the change that introduces it: what it does, why the platform can't, what it costs.
- Hand-rolled parsers are canonical (§2). A schema library (zod/valibot/ arktype) is justified only when shapes are genuinely large enough that hand-rolling is the bigger risk.
- One tool per job: one formatter (Prettier), one linter (ESLint), one test runner. A small standardized toolbox beats an array of specialized instruments, each with a dedicated manual.
- Scripts are TypeScript, not shell.
scripts/*.tsrun withnode --experimental-strip-typesortsx— cross-platform, type-checked, and runnable by everyone on the team instead of hitting Bash/OS-specific issues. - Commit messages are documentation. Write descriptive messages that inform
the reader — a PR description is invisible in
git blameand is not a substitute.
| Pattern | Do instead |
|---|---|
data as IProject (unvalidated) |
parseProject(data) / justified schema library |
any, as any, @ts-ignore |
unknown + narrowing; document the rare justified exception |
enum Status { … } |
string-literal union + as const array |
Partial<T> parameter |
explicit Pick<T, "a" | "b"> update type |
Record<string, any> |
Record<string, unknown> + validation, or a real type |
catch (e) { e.message } |
catch (e: unknown) → narrow or String(e) |
catching an assertion failure into a Result |
let programmer errors crash |
| boolean flags encoding state | discriminated union |
foo(bar: string, baz: string) swaps |
branded types |
| mutating a parameter | construct and return a new value |
non-null assertion x! |
handle undefined explicitly; restructure so it's unneeded |
null and undefined both used |
undefined for absence, consistently |
unbounded while / growing queue |
stated limit in LIMITS; fail fast at the bound |
| unbounded recursion | iteration, or a justified asserted depth bound |
assert(a && b) |
assert(a); assert(b); |
if (!(index >= count)) |
positive form: if (index < count) … else … |
| compound boolean conditions | nested if/else trees |
bare / for integer math |
Math.floor / Math.ceil / Math.trunc with intent |
| relying on a library's defaults | explicit options at the call site |
src, dest, abbreviations |
source, target |
| shell scripts in the repo | TypeScript scripts |
| new dependency without justification | platform/stdlib first; written rationale otherwise |
shared/ is the only cross-process contract surface. Read
docs/contracts.md before changing page trees, scans, preload methods, IPC
channels, or renderer bridge modules. Values from IPC, disk, browser messages,
drag data, and JSON.parse stay unknown until the matching shared or feature
boundary parser accepts them. Generated or edited code must pass
env -u ELECTRON_RUN_AS_NODE npm test, which includes strict TypeScript,
ESLint, production builds, contract tests, and the complete regression suite.
- Type-level behavior is tested too:
expectTypeOf(vitest) ortsdfor public API types, exhaustiveness helpers, and brand separation. - One parse-test per contract: known-good passes; each known-bad shape fails.
- Test the negative space exhaustively — not only valid data but invalid data, and valid data as it becomes invalid. Bugs live on that boundary (§9).
- Assertions are tested: violating an invariant throws, and the test pins the message.
- Runtime tests target the pure,
Result-returning core; boundary code is thin enough to need only smoke tests. - Where a state machine exists, property-based tests (e.g. fast-check) hunt the transitions you didn't think of. A fuzzer proves the presence of bugs, never their absence — the mental model and its assertions come first.
- No new
any, assertions, non-null assertions, or@ts-ignorewithout a comment justifying the exception - Every new boundary input has a parser and a test, including bound checks
- New state modeled as a discriminated union, not flags
- Switches over unions are exhaustive (
nevercheck / ESLint rule) - Public API signatures are honest (failure and absence in the type)
- Expected failures return
Result; programmer errors assert and crash — the channels are never mixed - Invariants are asserted: preconditions, postconditions, pairs on both sides of storage; ~2 assertions per function in core logic
- Every loop, queue, retry, and collection has a stated upper bound
- No function exceeds 70 lines; control flow is centralized, helpers pure
- Conditions are simple and positive; every
ifhas itselsehandled or asserted - Names are unabbreviated; units and qualifiers last; one name, one meaning
- Comments are sentences that say why; test files open with goal and methodology
- New dependencies carry a written justification
-
npm run check:changedpasses (tsc, ESLint, Prettier, policy scan)
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "verbatimModuleSyntax": true } }