-
No
anytypes unless absolutely necessary -
Prefer
export * from "./module"over named re-export-from blocks, includingexport type { ... } from. In pureindex.tsbarrel files (re-exports only), use star re-exports even for single-specifier cases. If star re-exports create symbol ambiguity, remove the redundant export path instead of keeping duplicate exports. -
No
private/protected/publickeyword on class fields or methods — use ES native#private fields for encapsulation; leave members that need external access as bare (no keyword). The only placeprivate/protected/publicis allowed is on constructor parameter properties (e.g.,constructor(private readonly session: Session)), where TypeScript requires the keyword for the implicit field declaration.// BAD: TypeScript keyword privacy class Foo { private bar: string; private _baz = 0; protected qux(): void { ... } public greet(): void { ... } } // GOOD: ES native # for private, bare for accessible class Foo { #bar: string; #baz = 0; qux(): void { ... } greet(): void { ... } } // OK: constructor parameter properties keep the keyword class Service { constructor(private readonly session: Session) {} }
-
NEVER use
ReturnType<>— it obscures types behind indirection. Use the actual type name instead. Look up return types in source ornode_modulestype definitions and reference them directly.// BAD: Indirection through ReturnType let timer: ReturnType<typeof setTimeout> | null = null; let stmt: ReturnType<Database["prepare"]>; // GOOD: Use the actual type let timer?: NodeJS.Timeout; let stmt: Statement;
If a function's return type has no exported name, define a named type alias at the call site — don't use
ReturnType<>. -
Check node_modules for external API type definitions instead of guessing
-
NEVER use inline imports — no
await import("./foo.js"), noimport("pkg").Typein type positions, no dynamic imports for types. Always use standard top-level imports. -
NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
-
Always ask before removing functionality or code that appears to be intentional
-
Use
Promise.withResolvers()instead ofnew Promise((resolve, reject) => ...):// BAD: Verbose, callback nesting const promise = new Promise<string>((resolve, reject) => { ... }); // GOOD: Clean destructuring, typed resolvers const { promise, resolve, reject } = Promise.withResolvers<string>();
This project uses Bun. Use Bun APIs where they provide a cleaner alternative; use node:fs for operations Bun doesn't cover.
NEVER spawn shell commands for operations that have proper APIs (e.g., Bun.spawnSync(["mkdir", "-p", dir]) — use mkdirSync instead).
Prefer Bun Shell ($ template literals) for simple commands:
import { $ } from "bun";
// Capture output
const result = await $`git status`.cwd(dir).quiet().nothrow();
if (result.exitCode === 0) {
const text = result.text();
}
// Fire and forget
$`do-stuff ${tmpFile}`.quiet().nothrow();Use Bun.spawn/Bun.spawnSync only when:
- Long-running processes (servers, daemons)
- Streaming stdin/stdout/stderr required
- Process control needed (signals, kill, complex lifecycle)
Bun Shell methods:
.quiet()— suppress output (stdout/stderr to null).nothrow()— don't throw on non-zero exit.text()— get stdout as string.cwd(path)— set working directory
Prefer await Bun.sleep(ms)
Avoid new Promise((resolve) => setTimeout(resolve, ms))
NEVER use named imports from node:fs or node:path — always use namespace imports:
// BAD: Named imports
import { readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
// GOOD: Namespace imports
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
// Then use: fs.readdir(), path.join(), etc.Choosing between node:fs and node:fs/promises:
- Async-only file →
import * as fs from "node:fs/promises" - Needs both sync and async →
import * as fs from "node:fs", usefs.promises.xxxfor async
Prefer Bun file APIs:
// Read
const text = await Bun.file(path).text();
const data = await Bun.file(path).json();
// Write
await Bun.write(path, data);Bun.write() is smart — it auto-creates parent directories and uses optimal syscalls:
// BAD: Redundant mkdir before write
await mkdir(dirname(path), { recursive: true });
await Bun.write(path, data);
// GOOD: Bun.write handles it
await Bun.write(path, data);Use node:fs/promises for directories (Bun has no native directory APIs):
import * as fs from "node:fs/promises";
await fs.mkdir(path, { recursive: true });
await fs.rm(path, { recursive: true, force: true });
const entries = await fs.readdir(path);Avoid sync APIs in async flows:
- Don't use
existsSync/readFileSync/writeFileSyncwhen async is possible - Use sync only when required by a synchronous interface
NEVER check .exists() before reading — use try-catch with error code:
// BAD: Two syscalls, race condition
if (await Bun.file(path).exists()) {
return await Bun.file(path).json();
}
// GOOD: One syscall, atomic
try {
return await Bun.file(path).json();
} catch (err) {
if (err?.code === "ENOENT") return null;
throw err;
}NEVER create multiple handles to the same path.
NEVER use Buffer.from(await Bun.file(x).arrayBuffer()) — just use readFile:
import * as fs from "node:fs/promises";
const buffer = await fs.readFile(path);Use Bun.JSON5 — never add json5 as a dependency:
const data = Bun.JSON5.parse(text);Use Bun.JSONL — never manually split and parse:
const entries = Bun.JSONL.parse(text);| Operation | Use | Not |
|---|---|---|
| File read/write | Bun.file(), Bun.write() |
readFileSync, writeFileSync |
| Spawn process | $\cmd`, Bun.spawn()` |
child_process |
| Sleep | Bun.sleep(ms) |
setTimeout promise |
| Binary lookup | Bun.which("git") |
spawnSync(["which", "git"]) |
| HTTP server | Bun.serve() |
http.createServer() |
| SQLite | bun:sqlite |
better-sqlite3 |
| Hashing | Bun.hash(), Web Crypto |
node:crypto |
| Path resolution | import.meta.dir, import.meta.path |
fileURLToPath dance |
| JSON5 parsing | Bun.JSON5.parse() |
json5 package |
| JSONL parsing | Bun.JSONL.parse(), .parseChunk() |
manual split + JSON.parse |
| String width | Bun.stringWidth() |
get-east-asian-width, custom |
| Text wrapping | Bun.wrapAnsi() |
custom ANSI-aware wrappers |
Bun.spawnSync([...])for simple commands → use$\...``new Promise((resolve) => setTimeout(resolve, ms))→ useBun.sleep(ms)existsSync/readFileSync/writeFileSyncin async code → useBun.file()APIsimport JSON5 from "json5"→ useBun.JSON5.parse()text.split("\n").map(JSON.parse)for JSONL → useBun.JSONL.parse()- Custom
visibleWidth()/get-east-asian-width→ useBun.stringWidth() - Custom ANSI-aware text wrapping → use
Bun.wrapAnsi()
| Command | Description |
|---|---|
bun check |
Biome check + type check |
bun lint |
Biome lint |
bun fmt |
Biome format |
bun fix |
Biome --unsafe + format |
- NEVER commit unless user asks
- Do NOT use
tscornpx tsc— always usebun check
When adding or changing tests, test the contract the system exposes — not the easiest internal detail to assert.
- Every new test must defend one concrete, externally observable contract: behavior, output shape, state transition, error mapping, or a regression-prone parsing boundary. If you cannot name the contract, do not add the test.
- Do not add placeholder tests, tautologies, or assertions that only prove the code executed.
- Prefer contract-level tests over implementation-detail tests. Avoid asserting internal helper wiring, field assignment, singleton identity, incidental ordering, or passthrough option forwarding unless another component depends on that exact detail as a documented contract.
- Do not duplicate coverage across abstraction levels. If an integration test already proves the behavior, delete the narrower unit test that only restates it through mocks.
- For error handling, prefer tests that trigger the real failure path and assert the surfaced error contract over tests that directly instantiate error classes or inspect purely internal metadata.
- Exact strings, ordering, and formatting should only be asserted when downstream code parses or materially depends on the exact bytes. Otherwise assert semantic content.
- Do not add tests for tiny, low-risk changes unless the change affects a real contract or a regression-prone edge case.
NEVER use console.log, console.error, or console.warn — use the structured logger:
import * as logger from "./logger";
logger.error("git fetch failed", { code: 128, repo });
logger.warn("stale lock", { age: 300 });
logger.info("server started", { port: 3000 });
logger.debug("checking repo", { path });The logger writes JSON lines to stderr with timestamp, level, pid, and flattened context. Default level is info; call logger.setLevel("debug") to include debug output.
- Keep answers short and concise
- No emojis in commits, issues, PR comments, or code
- No fluff or cheerful filler text
- Technical prose only