--- name: typescript-beautify description: Rules and before/after examples for writing senior-level TypeScript — precise types, discriminated unions, honest errors, deliberate async, truthful names, platform-first utilities, tests that mean something. Use when writing, refactoring, or reviewing TypeScript or JavaScript code, or when designing a TypeScript API or package. --- # Writing beautiful TypeScript You are writing TypeScript that a senior engineer will read, review, and maintain. "Working" is the floor, not the goal. Code is beautiful when every name tells the truth, the types carry the design, and nothing is there that doesn't earn its place. These rules are general. They apply to any project. Where a project's own conventions conflict with them, the project wins — match the surrounding code. --- ## 1. Trust the type system The type system is your collaborator, not an obstacle to silence. Almost every `as` cast is a design problem somewhere else. **Type values precisely at their source.** One loosely-typed constant forces casts into every consumer. Fix the declaration, not the call sites. ```ts // Bad: the object is complete, but the type says everything is optional. // Every consumer now needs `as Required` to use it. export const defaults: Partial = { retries: 3, timeoutMs: 5_000, verbose: false }; // Good: say what it is. Consumers need no casts, and the compiler // verifies completeness when a field is added to Settings. export const defaults: Required = { retries: 3, timeoutMs: 5_000, verbose: false }; ``` **Never re-validate what the type already guarantees.** Defensive re-parsing of a union is the compiler telling you the types are wrong. ```ts // Bad: `grade` was typed as string, so now we string-parse our own union back. function gradeTone(grade: string) { const g = grade.trim().charAt(0).toUpperCase(); if (g === "A" || g === "B") return "good"; // ... branches for letters that can never occur } // Good: keep the union, make handling exhaustive. type Grade = "A" | "B" | "C" | "D" | "F"; function gradeTone(grade: Grade): Tone { switch (grade) { case "A": case "B": return "good"; case "C": return "warn"; case "D": case "F": return "bad"; } } ``` **Protect exhaustiveness against tomorrow's variant.** An annotated return type catches a missed `case` in an expression-shaped switch, but statement-shaped handlers need the gap made loud: ```ts switch (event.kind) { case "open": handleOpen(event); break; case "close": handleClose(event); break; default: event satisfies never; // compile error the day a new kind is added } ``` **Don't widen.** `Record` where the keys are a known union, `string` parameters where a literal union exists, `any`/`unknown` laundering — each widening deletes information the compiler was ready to enforce, and usually forces a redundant runtime check right next to it. **Derive, don't re-declare.** When a list of keys, a union, and an interface must agree, write one source and derive the rest. Duplicated key lists drift. ```ts // One source of truth; the type and the runtime list can never disagree. const paletteKeys = ["primary", "accent", "background", "text"] as const; type PaletteKey = (typeof paletteKeys)[number]; type Palette = Record; ``` **No type gymnastics when a name exists.** `Parameters[0]`, `Awaited>`, `ReturnType` in a public signature, `Exclude` — if the concept matters enough to reference, it matters enough to name and export. Indexed-access acrobatics are for library internals, not for passing your own types around. **A type parameter must earn its letter.** A generic that appears in exactly one position relates nothing to nothing — it's `unknown` wearing a costume. ```ts // Bad: T constrains nothing and tells the caller nothing. function logValue(value: T): void { console.log(value); } // Good: say what it is. function logValue(value: unknown): void { console.log(value); } ``` Reach for a type parameter when it *links* positions: an argument to the return type, two arguments to each other, a field to a method. **A cast needs a written justification.** If you must use `as`, the code gets a one-line comment stating the invariant that makes it sound and why the compiler can't see it. If you can't write that sentence, don't write the cast. Prefer, in order: restructuring so no cast is needed → a type guard → `satisfies` → a commented cast. `as unknown as T` in non-test code is a defect. **Strict flags are design pressure, not noise.** Under `noUncheckedIndexedAccess`, restructure the loop or use a checked accessor instead of asserting `!`/`as`. Under `exactOptionalPropertyTypes`, build objects with conditional spread (`...(x !== undefined && { x })`) instead of assigning `undefined` and casting. --- ## 2. Model the domain in types **Make invalid states unrepresentable.** If some fields only make sense in some modes, that's a discriminated union, not a bag of optionals. ```ts // Bad: which fields apply depends on `kind`, but nothing enforces it. interface FieldSpec { kind: "number" | "enum" | "boolean"; min?: number; max?: number; choices?: string[]; } // Consumers write `spec.min ?? 0` and a forgotten `max` silently validates "0 to 0". // Good: each kind states its requirements; the compiler rejects nonsense. type FieldSpec = | { kind: "number"; min: number; max: number } | { kind: "enum"; choices: readonly [string, ...string[]] } | { kind: "boolean" }; ``` The same applies to function options: a `download` call must not carry an `uploadChunkSize` field that "doesn't apply here". **Name every public type.** Anonymous inline object types in exported signatures force consumers into `SomeType["field"]` lookups and `ComponentProps` gymnastics. If it crosses a module boundary, it has a name. **Unions of literals, not `enum`.** `"info" | "warn" | "error"` erases at runtime, compares structurally, and needs no import at call sites. When the values are also needed at runtime, derive the union from an `as const` list (§1) — `enum` adds a runtime object, nominal comparison, and (numeric enums) unsound reverse lookups for nothing the union doesn't already give you. **Return types tell the truth.** Returning `0`, `""`, or `[]` as an "empty" sentinel when the value is indistinguishable from real data is a lie — return `null` (or throw) and let the caller decide. Conversely, don't return `T | null` when the function can in fact always produce a `T`. **The export surface is designed, not accumulated.** A barrel (`index.ts`) is a public API: it contains exactly what consumers should use — no internal helpers "just in case", no dead exports, nothing missing that a consumer plainly needs. One canonical import path per symbol; don't offer both a barrel export and a deep path for the same thing. Never keep compatibility aliases for names that never shipped. **Every field must have a reader.** A required field that no consumer reads forces producers to fabricate values just to satisfy the type — that's the type system telling you the field shouldn't exist. Delete it or actually use it; never carry data "for later". **Identity covers exactly what matters.** Hashes, cache keys, memo dependencies, and dedupe signatures are computed over precisely the inputs that define identity — no more, no fewer. Hash cosmetic fields alongside semantic ones and every label tweak silently fragments your grouping; omit a real input and stale results look fresh. **Don't build machinery for cases that don't exist.** A severity field whose "warning" variant nothing ever produces, a strategy pattern with one strategy, an options flag every caller passes the same way — single-variant machinery is complexity rent with no tenant. Add the second variant when it actually arrives. --- ## 3. Errors are part of the API **Throw `Error` subclasses, never object literals.** A thrown `{ code, message }` has no stack trace, can't be narrowed with `instanceof`, and forces duck-typed rescue code downstream. ```ts // Bad throw { code: "timeout", message: "Request timed out", recoverable: true }; // Good export class RequestError extends Error { constructor(readonly code: RequestErrorCode, message: string, options?: ErrorOptions) { super(message, options); this.name = "RequestError"; } } throw new RequestError("timeout", "Request timed out"); ``` If an error's *data* must be serialized (postMessage, JSON state), keep a plain data interface for the serialized shape and convert at the boundary — the thrown thing is still an `Error`. **Narrow with `instanceof`, not shape-sniffing.** `"code" in e && "message" in e` followed by `e as MyError` accepts any object with those keys. If you own the error class, `instanceof` is exact and free. **A `catch` binds `unknown` — narrow before touching it.** `e.message` on an unnarrowed catch is the same shape-guessing this section bans. Check `instanceof` for your own classes, `e instanceof Error` for the rest, and fall back to `String(e)` for the truly unknown. **Preserve `cause`.** When wrapping or translating an error, pass `{ cause: original }`. Swallowing the underlying `EACCES` to rethrow a generic message destroys the diagnostic a user needed. **Error codes must tell the truth.** Never map "unknown phase" to some plausible-sounding specific code. If no accurate code exists, add one; a diagnostic that lies is worse than none. **One error contract per package.** If the documented pattern is `instanceof ConfigError`, every validation path throws `ConfigError` — a helper that throws bare `new Error(...)` for the same class of problem breaks the contract for every consumer who followed the docs. --- ## 4. Async code is deliberate Most concurrency bugs in generated TypeScript aren't exotic races — they're a missing `await`, an accidental serialization, or an error that evaporated with a floating promise. **Every promise is awaited, returned, or deliberately handed off.** A promise nothing waits on reports success before the work happens and turns rejections into unhandled noise. `forEach` never takes an async callback — it discards the promises by design. ```ts // Bad: returns before any save completes; failures vanish. items.forEach(async (item) => { await save(item); }); // Good: concurrent when items are independent… await Promise.all(items.map((item) => save(item))); // …sequential when order matters or the target can't take the fan-out. for (const item of items) await save(item); ``` **Choose sequential vs. concurrent on purpose.** `await` in a loop over independent items serializes work for no reason; `Promise.all` over ten thousand fetches is a self-inflicted flood. Independent and bounded → `Promise.all`; dependent or ordered → a loop; large → batches or a concurrency-limited pool. The shape of the code should show the choice was made, not inherited. **Know your failure semantics.** `Promise.all` rejects on the first failure and abandons its siblings' results; `Promise.allSettled` never rejects and makes you inspect every outcome. Choosing `allSettled` and then reading only the fulfilled entries silently swallows every error — a worse lie than crashing. **Pass the `AbortSignal` all the way down.** A function that accepts a `signal` and forgets to hand it to its own `fetch` and inner calls advertises cancellability it doesn't have. Accept it at the boundary, thread it through every async call, and check it between expensive steps. --- ## 5. Names tell the truth - **Files are named for what they export.** `useTheme.ts` that exports no `useTheme` sends every reader on a hunt. A file named `registry.ts` must contain a registry, not one resolver function. `shared.ts`, `utils.ts`, `helpers.ts`, `lifecycle.ts` are where names go to die — split by concept and name the concepts. - **Functions are named for what they do.** A formatter is `formatLatency`, not `latency` — call sites read as `formatLatency(ms)`, not as a measurement. A function computing a `configSignature` is `configSignature`, not `signatureConfig`. - **Parameters are named for what they receive.** A parameter used for both uploads and downloads is `transport`, not `uploadTransport`. Passing a variable named `downloadX` into a parameter named `uploadX` is a bug report waiting to be filed. - **One name per concept, one concept per name.** Two identical unions with different names in sibling modules, or an interface and a class sharing one name so every import needs an alias — merge or rename. When vocabulary has a scheme (`Raw*`/`Resolved*`, `*Config`/`*Result`), no stragglers. - **Don't let locals shadow options.** A local `const retry = new RetryPool()` next to `options.retry: boolean` makes every following line ambiguous. --- ## 6. Files and structure - **Group by domain, not by kind** — `billing/`, `auth/`, not `types/`, `interfaces/`, `impls/`. Within a domain, colocate a type with its implementation until a second consumer exists; a 25-line `fooTypes.ts` with one consumer is fragmentation, while a shared vocabulary imported by many modules earns its own file. - **Split god-files by concern, not by size.** A "types" file that actually holds the package's entire public API contract is mislabeled and misplaced — the line count is a symptom, the mixed concerns are the problem. Conversely, don't shred a coherent 400-line module into six 60-line fragments that can only be understood together. - **Test placement follows one convention per repo** — pick colocated `__tests__/` or a mirror tree and apply it everywhere. A 900-line catch-all test file at the root testing eight modules belongs split next to those modules. Extract shared fixtures instead of copy-pasting 45-line objects between test files. - **Test doubles live with tests, not in production modules.** A fake/synthetic implementation inside the production module graph is a loaded gun; ship it behind an explicit testing entry point (`./testing`) if consumers need it. - **Dead code is deleted, not exported.** Unused components, unreachable branches, feature flags for features that never shipped, "keep it around just in case" — delete. Git remembers. Every dead export dilutes the API and misleads the next reader. - **Every layer earns its keep.** A wrapper that calls one function with that function's own defaults, a "context" object threading values its consumers already default to, a module that only re-exports for one importer — inline them. Indirection is a real cost; it must buy a real contract change. - **Components/UI:** presentational components take data and callbacks; they don't fetch, don't own global state, and don't self-hide on `null` while siblings let the parent gate. Reusable primitives (an animation wrapper, a dismissible alert) get extracted the moment a second copy appears — copy-paste × 5 with slightly different state names is how design systems rot. --- ## 7. Own your data; respect everyone else's - **Never mutate — or freeze — what you don't own.** Objects received from a caller, config the user authored, arrays from another module: clone at the boundary before transforming, freezing, or storing them. `Object.freeze` on a caller's object is a mutation of *their* state, felt far from your code. - **Every subscription has an unsubscription.** A listener added to a long-lived emitter (an `AbortSignal` for the whole run, a global event bus) inside a per-call helper is a leak by construction. Use `{ once: true }`, return a disposer, or attach to a scope that dies with the call. - **One owner per piece of global state.** Two APIs that both write `document`-level theme tokens, with a docstring warning not to combine them, is a footgun the design documents instead of preventing. Pick one sanctioned writer; remove or subordinate the other. - **Don't compute what you're about to discard.** Structured-cloning a result with megabytes of samples and then deleting the samples from the clone does the expensive copy for nothing — strip first, then clone. Order operations so waste never exists. - **Control-flow signals are typed values, not magic strings.** Five modules throwing and string-matching `"Run aborted"` is a protocol nobody declared. Use a shared constant, a dedicated error class, or the abort reason itself. --- ## 8. Don't reinvent the platform Before writing a utility, ask: does the platform or the standard library already do this? Reimplementations are where the bugs live. | You're about to write… | Use instead | | ---------------------------------- | ---------------------------------------------- | | deep clone via `JSON.parse` | `structuredClone` | | manual timeout + abort plumbing | `AbortSignal.timeout`, `AbortSignal.any` | | an argv `for`-loop parser (Node) | `node:util` `parseArgs` | | a recursive directory walker | `fs.readdir(dir, { recursive: true })` | | a regex "parser" for YAML/JSON/TOML| a real parser (`yaml`, `JSON.parse`, `smol-toml`)| | URL string concatenation | `new URL(path, base)` | | number/date/list formatting | `Intl.NumberFormat` / `DateTimeFormat` / `ListFormat` | | a bespoke event emitter | `EventTarget`, or the tiny one you already have | Hand-rolling *is* legitimate when a dependency is disproportionate (e.g. a zero-dependency library validating its own config). Then the bar is higher, not lower: table-driven, single source of truth, exhaustive over the schema — not twelve copy-pasted `checkX` functions with duplicated key lists. Regex-rewriting build output to fix imports, sniffing globals to guess the environment, post-processing generated files — these are workarounds for a misconfiguration somewhere upstream. Fix the configuration. --- ## 9. No hidden behavior - **Never switch behavior on sniffed environment.** Code that silently substitutes fake data when it detects `NODE_ENV === "test"` will one day run in a consumer's CI and produce plausible lies. Test seams are explicit parameters (injected adapter, factory option), never ambient magic. - **Scripts and names must be honest.** A `typecheck` script that only runs a syntax check, a `test` script that tests nothing, a "provider" component that provides no context — each one teaches the reader to distrust every other name in the repo. - **No compatibility layers for things that never shipped.** `@deprecated` fields and migration shims in a pre-1.0 codebase are pure weight; delete the old shape instead. - **Fail loudly at the right layer.** Validate at the boundary (user input, config files, network payloads — this is what `unknown` + validation is for), then trust the types inside. Sprinkling defensive checks through internal code hides real bugs and doubles the reading load. - **Wire it or delete it.** A customization system where half the knobs are consumed and the other half silently ignored (config fields no code reads, hardcoded copy sitting next to the field meant to replace it) promises flexibility it doesn't deliver. Every exposed knob is honored, or it doesn't exist. --- ## 10. Tests that mean something - **Test your own contract, not your dependencies'.** A test that exercises a thin wrapper by re-asserting the wrapped library's behavior is a copy of that library's test suite with worse coverage. If the wrapper has no logic of its own to test, that's evidence the wrapper shouldn't exist. - **Assert through the user's handles.** Roles, accessible names, visible text, returned values — not internal CSS classes, private fields, or DOM structure incidental to behavior. Tests coupled to internals break on every refactor and pass on every real regression that keeps the markup. - **Never bypass the harness's safety checks.** Dispatching `el.click()` via `evaluate` to dodge a browser-test framework's actionability checks doesn't fix a flaky test — it silences it. In practice, the "flake" is often real (an element genuinely covered by another); the harness was right. - **One fixture builder, not N copies.** A 45-line result object copy-pasted across three test files with trivial differences is three files that break on every schema change. Write one builder with overrides, colocated with the domain it fakes. - **Delete empty suites.** A test config with `--passWithNoTests` guarding zero test files is scaffolding pretending to be coverage. Write the tests or remove the ceremony — a green checkmark must mean something ran. --- ## 11. Package metadata is a contract with the toolchain - **Machine-read fields must be true.** `sideEffects: false` on a package whose worker entries run top-level registration invites the bundler to tree-shake your program away. A `files` whitelist that ships test files, a missing `exports` path, an absent `engines` — each breaks silently, later, on someone else's machine. And know your packer's quirks: npm pack always strips files named `.gitignore`, so templates ship one under another name. - **Declare a platform floor, then code to it.** Set `engines`/targets honestly and delete every fallback below the floor: a `structuredClone` polyfill on an ES2022 target, `typeof window` guards in browser-only code — dead weight that implies support you don't provide. Below-floor users deserve a clear error, not a SyntaxError. - **Let the tool own the graph.** A hand-ordered chain of seven build commands re-encodes the dependency graph the workspace manager already knows — and drifts the first time a dependency changes. Declare dependencies where the tool reads them and use its topological execution. --- ## 12. Comments and docs - Comments state **constraints and reasons the code can't express** — the invariant behind a cast, the spec quirk behind a weird branch, the reason an obvious alternative was rejected. Never narrate what the next line does, and never leave change-log commentary ("moved from X", "new approach") in code. - Doc comments on public API describe the contract: inputs, outputs, failure modes, units (`timeoutMs`, `sizeBytes` — put units in names, not just docs). - If a comment claims something ("single source of truth", "never throws"), it must be true. A doc comment that lies is worse than none — verify claims against the code whenever you touch either. --- ## 13. Don't write Python — or Java — in TypeScript TypeScript has its own idiom. Code that transliterates another language's habits reads as foreign and throws away the type system. The common accents to avoid: | Python habit carried over | TypeScript idiom | | ---------------------------------------------------------- | ----------------------------------------------------------------------- | | `snake_case` functions/variables, `SCREAMING` module consts | `camelCase` values, `PascalCase` types/classes; match the repo's file naming | | dict-of-whatever: `Record` as the data model | declare an `interface` or discriminated union; use `Map` only for truly dynamic keys | | `isinstance`-style sniffing: `typeof x === "object" && "foo" in x` chains | model a union up front; narrow with the discriminant or `instanceof` | | EAFP: wrap everything in try/catch and carry on | validate at the boundary, then trust the types; catch narrowly, never swallow | | returning `None`-ish sentinels (`-1`, `""`, `{}`) | return `null` (the type says so) or throw a typed error | | kwargs bag: one `options: any` / all-optional dict | a named, typed options object; discriminated union when fields are modal | | positional tuple returns `[ok, value, err]` | a named object (`{ ok: true; value } \| { ok: false; error }`) | | top-level procedural script code in modules | modules export functions/values; only the entry point executes | | truthiness tests on numbers/strings where `0`/`""` are valid | explicit comparisons: `x !== undefined`, `items.length > 0` | | `getattr`-style dynamic access with string keys | `keyof`-typed access, mapped types — dynamic strings only at real boundaries | | module-level mutable globals as shared state | pass state explicitly; construct and inject | Python isn't the only accent. The Java one: | Java habit carried over | TypeScript idiom | | ----------------------------------------------------------- | ------------------------------------------------------------------------ | | `IUser` / `UserImpl` pairs with a single implementation | one type; introduce the abstraction when the second implementation arrives | | getter/setter pairs wrapping plain fields | `readonly` fields; a real property only when it's computed or guarded | | `AbstractWidgetFactoryManager` class hierarchies | functions and plain data; a class only where identity, state, and behavior coincide | | a class of static methods as a namespace | module-level functions — the module *is* the namespace | | builder classes for simple construction | an object literal checked against a named type | The tell that you're doing it: runtime checks re-deriving facts a type declaration would have made free. When you notice one, fix the types, not the check. --- ## 14. Working rules - **Run the project's gates before calling anything done** — formatter, linter, typechecker, tests. Zero errors *and zero warnings*. If the repo defines a combined check script, that's the bar. - **Fix causes, not call sites.** When a change forces casts or workarounds in several places, stop patching consumers: the declaration/design one level up is wrong. Prefer the structural fix over the local band-aid. - **Second copy: notice. Third copy: extract.** Apply judgement — two similar lines are fine; two 40-line ritual blocks are not. - **When renaming or moving files, use `git mv`** so history follows. - **Leave things consistent.** A half-migrated codebase (two error styles, two test conventions, two naming schemes) is worse than either style alone. When you introduce the better pattern, migrate all instances or none. --- ## 15. The final pass Before calling the work done, run the gates (§14), then run this list against your diff. Each "yes" is a defect to fix now, not a caveat to mention: - [ ] Any `as` cast without a one-line invariant comment? (§1) - [ ] Any new export nothing imports, or required field nothing reads? (§2, §6) - [ ] Any `throw` of something that isn't an `Error` subclass? Any wrapped error that dropped its `cause`? (§3) - [ ] Any promise not awaited, returned, or deliberately handed off? Any async `forEach` callback? (§4) - [ ] Any file whose name no longer matches what it exports? (§5) - [ ] Any utility the platform already provides? (§8) - [ ] Any behavior switched on a sniffed environment instead of an explicit parameter? (§9) - [ ] Any test asserting internals instead of user-visible behavior? (§10) - [ ] Any comment narrating the diff ("moved from…", "now uses…") instead of stating a constraint? (§12) - [ ] Anything left half-migrated — two styles where there was one? (§14)