Coding style
The "why" behind the choices in our ESLint config, our TypeScript config, and our .editorconfig. Read CONTRIBUTING.md first if you need the "what" (the rules themselves and how to run the tools).
.mts everywhere
Source files are .mts, never .ts. The reason is moduleResolution: "bundler" plus isolatedModules: true: every import has to resolve to a concrete file, which means imports must include the extension. .mts says "this is an ES module" without leaning on package-type guesswork.
The downside is that some tools historically treated .mts as a special case. That's mostly settled now (Node, tsx, Vite, Vitest, esbuild, tsup all handle it). When something doesn't, we fix the tool, not the extension.
ES2022 target
The tsconfig targets esnext for source and the project as a whole runs on ES2022 features. We use:
#privateclass fields. Real privacy, not the_convention.- Top-level
awaitwhere it makes sense. Object.hasOwn, structured clone, AbortController, AbortSignal.
Anything older than ES2022 is fair game in our supported browsers, so we don't transpile it down. If a feature lands in modern browsers and solves a real problem, we use it. If we lose support for a browser because of it, we say so in the README's browser-support section.
No semicolons
The .editorconfig and ESLint both enforce no semicolons. The shorter-than-expected rule is "we write ASI-safe code": no lines starting with (, [, `, +, -, or / that could be parsed as a continuation of the previous statement. ESLint catches the cases that matter.
This is a taste call, not a correctness call. If you don't like it, the review is not the place to relitigate it.
Tabs
Tabs for indentation. Spaces inside a line for alignment when needed. The .editorconfig enforces this and most editors auto-switch.
The reason is accessibility: tab width is a per-reader preference. Some people read with two-wide tabs; some with eight. Spaces lock everyone to one choice. See this thread for the longer argument, including how it matters for readers with low vision or dyslexia.
Single quotes
Single quotes for strings, double quotes only inside strings that include a single quote. Template literals for interpolation. This is a taste call too; it's just consistent across the codebase.
Full names over abbreviations
// No
const btn = ...
catch (err) { ... }
const el = document.getElementById('app')
// Yes
const button = ...
catch (error) { ... }
const rootElement = document.getElementById('app')The exception is #app (the conventional HTML id) and a small set of DOM-canonical names where the abbreviation is the standard term (href, url, id). When in doubt, write it out.
The reason is that abbreviations age badly. el is fine when you wrote it; six months later, in a function with three different variables, it isn't.
TSDoc on public API
Every exported function, type, and class has a TSDoc block. Internal helpers don't.
The TSDoc style for this repo:
- No em-dash. The CLAUDE.md TSDoc rules call this out specifically.
- Plain human language. Don't write "facilitates the management of..." when you mean "manages...".
- Copy the style of nearby comments. If existing comments are short and example-led, write yours that way.
- Include an
@exampleblock when the API isn't obvious from the signature.
This rule applies to docs prose too. See ../contributing/ for the full writing-style notes.
No refactor commits
We don't use refactor in Conventional Commits. If the change has no observable behaviour change, it's chore. If it has one, it's feat, fix, or perf.
The reason is honest semver. refactor is a category that hides behavioural changes from semantic-release. Either the behaviour changed (and the version should reflect it) or it didn't (and chore makes that clear).
"Don't add features beyond what the task requires"
Pull requests are scoped. A bug fix doesn't grow into a refactor of the surrounding file. If you spot something else worth doing, leave a TODO or open an issue and do it in its own PR.
This is partly aesthetic and partly practical: small PRs review faster, break less, and revert cleaner.
What the linter is for
ESLint catches the rules that are worth catching at edit time: unused locals and parameters, missing imports, style consistency, no-explicit-any. Run it before opening a PR.
What ESLint isn't for: opinions about how a function should be structured, or whether a name is good. The reviewer does that.
One custom rule is worth knowing about before it fails your build. local/comment-line-wrap applies the line-wrapping rule from CLAUDE.md to block comments: a line of prose that stops mid-sentence, with the rest of the same paragraph on the line below, is a hard wrap and gets flagged. Put the paragraph on one line, or break after a ., ,, ;, :, ! or ?. Tags, list items, tables, fenced blocks and anything under @example are left alone, and so is a line ending in a \ hard break. There is no autofix, because the right join is sometimes a rewrite.
One job per file
A module does one thing. When it starts doing two, split it, and let the file names say what each half is for.
Three signals that it already happened:
- A
// ------divider. If a file needs a rule drawn across it to separate "the thing" from "the machinery", that line is the split point, not a heading. Use it to find the seam and then delete it. - An entry point with code in it. The public entry of a package is
src/_module/<name>.mts, and it is a barrel: a@moduledoc block and re-exports, nothing else. A plugin, an options type and a code generator sharing one entry file is three files wearing one name. - Length. There's no hard limit, but past roughly 150 lines of source it's worth asking what the second job is. Some files earn their length; most don't.
There is no index.mts in this repo. Entries live in src/_module/, named for the package or the subpath they serve, and tsdown.config.mts globs src/_module/*.mts so adding a subpath is a file plus a key in exports. See adding packages for the full layout.
When a file splits, how the pieces are named says which kind of split it was.
Dotted names are for one subject that got long: route.mts, route.match.mts, route.tokens.mts in the router are all about routes, and reading one tells you roughly what the others hold. A folder is for one entry point with parts that do different jobs: component.mts sits next to component/, which holds the styles, the classes and the generic component it leans on. node-middleware.mts and node-middleware/ are the same shape, with the loader and the chain in the folder.
The folder gets short names, no barrel, and is imported by path. If you find yourself writing thing.this.mts and thing.that.mts for parts that have nothing to say to each other, it wanted a folder.
Types live next to the function whose signature they are. Don't collect them into a *.types.mts bucket: that groups them by what they are rather than by what they're about, so the file has no subject and nothing in it explains anything about anything else in it.
A type several modules genuinely share gets its own file, named after the concept. packages/elements/src/children.mts is the pattern: ElementChild and ElementChildren live there because children are a thing worth naming, not because they happen to be types.
Import cycles are not a reason to reach for a bucket. Type-only imports are erased, so two files importing each other's types is not a cycle at runtime. If the cycle is real, it's the values that are tangled, and the fix is to pass the function what it actually needs.
Copying between packages
Before copying anything from one package into another, put it somewhere both can import instead.
For the adapters that place is @rooted/adapter, under src/utility/, exported from the package so third-party adapters get it too. The same applies to generated code: buildServerPreamble exists because the Fastify and Express server.mjs templates were 63 identical lines out of 90, and two copies of a route table drift into two servers that disagree about what a route is.
This is not about saving lines. It's that duplicated logic gets fixed once and stays broken in the other copy, and nobody notices until the two halves are far enough apart to be a bug report.