Skip to content

TypeScript Coding Standards

Craft rules for TypeScript in any Arda repository. Kotlin has its own page — see Kotlin Coding Standards; React state, context, and component API shape have theirs, linked under Related pages.

Each rule states the reach it replaces rather than the principle behind it, because the reach is the moment the rule has to fire. “Comment only non-obvious why” is easy to agree with and then break; “when you finish a function and reach for a JSDoc above it, one or two lines or none” names the moment you are actually standing in.

RuleDetail
1A comment is one or two lines, or none.Comments
2No ticket ids and no decision history in source. The commit message and the PR body are where people look for history.Comments
3Never restate the line below, the signature above, or the test name.Comments
4A config file holds configuration. No prose, including in JSONC formats that permit it.Configuration and policy
5Key a rule on the manifest field that already means it — peerDependencies, engines, exports — not on a list you maintain by hand.Configuration and policy
6A new config file needs a second caller. If the value fits on the command line and has one caller, put it on the command line.Configuration and policy
7The barrel is the module’s public API. Export exactly what callers may depend on; anything private stays unexported and is reached only from inside the module.Modules and imports
8A deep import path is a diagnostic. Fix the structure, not the path.Modules and imports
9Name a file after the one thing it holds. If the name has to stay vague to stay accurate, split the file.Decomposition and naming
10Size is part of a quality pass. Comment density and duplication are not proxies for it.Decomposition and naming
11Keep every exported surface as slim as it can be.Decomposition and naming
12Scope verification to what the edit could break.Verification scope

One or two lines, or none. Self-documenting code — a well-named function, a typed signature — gets no comment at all.

When you reach for a comment to say what a line does, to cite the ticket that motivated it, or to record why a decision was taken, delete it. History belongs in the commit message and the PR body, which is where a reader goes looking for it and where it stays accurate. A comment naming a ticket outlives the ticket.

Keep the comment that stops a reader breaking something — the constraint they would otherwise remove by accident:

// `||` not `??`: '' is not an enum member
const unit = raw.unit || DEFAULT_UNIT;

State that once, at the site that owns the rule. Repeating it at every call site turns one fact into several that can disagree.

Test names are documentation. Never preface a test with a comment restating its name.

The failure concentrates in new code. Writing a module from scratch, there is no surrounding density to match and the reasoning is freshest, so the reach for explanation is strongest. A 20-line JSDoc on a 40-line function is a design document filed in the wrong place. Watch for it hardest exactly where it feels most justified.

A config file holds configuration. If a setting needs a paragraph, the paragraph belongs in the README or the knowledge-base page that covers the area, and the setting belongs on its own line. This holds for JSONC formats that accept comments: a tsconfig.json full of prose is still the wrong home for it, and it dates faster than the page would.

Key a rule on the field that already means it. Before inventing a config surface to express a policy, look for the field the ecosystem already uses to say it:

Question the rule asksThe field that answers it
May this package import the framework?peerDependencies — the host provides it, do not install a second copy
Does this run in a browser or in Node?engines.node
What is this package’s public surface?exports

A rule keyed on a manifest field stays correct as packages are added and changed, because the thing it reads is the thing the package manager reads. A parallel allowlist in a config file is a second source of truth, and its drift is invisible exactly where it matters: the list still says “allowed” long after the dependency is gone.

A new config file needs a second caller. tsc -b a b c does not need a fourth tsconfig to hold three paths. A file earns its place by being read by more than one caller, or by holding something a command line cannot express. Otherwise it is one more artifact to find, keep current, and explain.

When consolidating configuration, count the files as well as the settings. Unifying the contents of four configs and leaving four configs behind has not finished the job.

Never settle a race with a flag. enabled:, skip:, if (!busy) mute one owner under a condition that holds today. Both owners stay in place, so the race returns for whoever next changes that condition — and the flag is now load-bearing. Give the outcome one owner, or make each trigger genuinely distinct.

A module with more than one related file lives behind a directory with an index.ts barrel, and is imported through that barrel — never by deep path. A genuinely single-file unit can sit flat in its category directory, re-exported from that category’s barrel.

The barrel is the module’s public API. Anything private is not exported from it. That is the whole job of the file, and it is the half that does the work: a barrel that re-exports everything in the directory is not a boundary, it is a directory listing with extra steps. Decide what callers are allowed to depend on, export exactly that, and leave the rest reachable only by relative import from inside the module. Then the module’s internals are free to change, because nothing outside could have coupled to them.

This makes the deep-path prohibition enforceable rather than stylistic. If a caller can only see what the barrel exports, a deep path is the only way to reach past the API — which is why one showing up in a diff is worth stopping on.

A deep import path is a diagnostic, not an inconvenience. When you find yourself writing @/components/ItemTable/parts/toolbar/filters, the reach is telling you the structure is wrong: either the thing you want is public surface and the barrel should export it, or it belongs in a module of its own. Fix the structure rather than lengthening the path. Two or three segments to reach a module is ordinary; four or five means the boundaries were drawn around the wrong things.

Where the module is a published package, exports in package.json is the enforcement point for the same rule — see Configuration and policy.

Barrels, and the performance advice about them

Section titled “Barrels, and the performance advice about them”

There is well-known advice to avoid barrel imports, aimed at third-party packages whose entry point re-exports thousands of modules. Read it before acting on it: the obvious reading costs you a boundary and, in a Next application, buys nothing.

Keep the barrel. It is how a module is organised and how its public surface is declared. That holds for your own modules and for the library’s — the answer to a heavy dependency is not for every call site in the app to reach past its front door and hard-code an internal path.

In a Next application the compiler already does the rewrite. optimizePackageImports turns import { Check } from 'lucide-react' into a direct import at build time, and Next ships a default list — 75 packages as of 16.1 — which includes every library the advice names: lucide-react, @mui/material, @mui/icons-material, @tabler/icons-react, date-fns, rxjs, react-use, @headlessui/react, ramda, lodash-es. For any of those, a hand-written deep path is pure cost.

So the rule is: write the ergonomic import, and check the list. When a heavy package falls outside the default set — react-icons is the known case, because Next cannot express a react-icons/* wildcard as a default — add it to optimizePackageImports in next.config.ts. One config entry, once, against an import convention that every reviewer then has to police forever. This is the same move as keying a rule on the manifest field that already means it: let the tool that already knows how to do this do it.

Tree-shaking also does more than that advice credits. For a library shipping ESM with sideEffects: false, unused modules are already removed from the production client bundle, so bundle size is the wrong cost to quote. What survives tree-shaking is dev-server boot and HMR (dev builds don’t shake), server-side cold start (node_modules are commonly left external, so the barrel is evaluated at runtime), and build time (the bundler parses the whole graph in order to shake it) — which is precisely what optimizePackageImports addresses.

A file holds one thing and is named for that thing. The test is the name: if it has to stay vague to stay accurate — ItemTable.tsx holding the table, its toolbar, its row menu, its column model and three formatters — the file is doing more than one job. Length is the symptom; the vague name is the diagnosis.

The same test applies one level down. A named function replaces the comment that would have introduced the block, which is why decomposition and rule 1 are the same rule seen from two sides.

Size is part of a quality pass. A pass that reviews comments, duplication and naming, and reports itself finished with a 1,000-line spec file and four 200-line functions untouched, has reviewed the prose and not the structure. Measure length; comment density and duplication are not proxies for it.

Decomposition also finds defects that reading does not. Splitting a 552-line table component surfaced an onClick={onToggle} feeding a MouseEvent into a displayed?: boolean parameter, which two reading passes over the same file had walked past.

Keep every exported surface as slim as it can be. Each additional export is something a caller can couple to, and the coupling outlives the reason for the export.

Match verification to what the edit can actually break. A change to comments, whitespace, or Markdown cannot fail a typecheck or a test suite; running them anyway is not diligence, it is a reflex. Re-run the checks the change could plausibly fail, at the narrowest target path that covers it.

Derive the property; do not sample for it. Repeated green runs are not evidence of determinism — microtask interleaving is deterministic for a fixed set of mocks, so a loop re-confirms one interleaving and says nothing about the one that changes when a mock, a dependency bump, or a config constant does. Read the ordering out of the code instead. The same argument rules out generating machine load to reproduce a timing-sensitive failure: control the clock or the async boundary — fake timers, a manually-resolved promise, a shortened timeout window — rather than the load.

See Mocking and Testing Patterns for the frontend specifics, including how to audit a test for flakiness from its code.


Copyright: (c) Arda Systems 2025-2026, All rights reserved