typescript
Esposter TypeScript conventions — banned patterns (any, Omit, !, forEach, parameter properties), error handling with InvalidOperationError, control flow guard clauses, and enum ref defaults. Apply whe
SKILL.md.claude/skills/typescript/SKILL.md
GitHubTypeScript Conventions
Settled — do not re-propose
- Turning
typescript/consistent-type-importson for.vue— oxlint skips the rule there, since it cannot tell from the script block whether the template uses an import as a value, and nothing in the ESLint config reaches it; the.tshalf is on (disallowTypeAnnotationsoff, becausevi.mock(import(…))is the sanctioned Vitest idiom), so a class used only in type position takesimport typeby lint, and a.vuefile keeps it by reading. - A raw non-printing byte in a literal — it renders as nothing in a diff, a terminal or an editor, so no reader can tell it from an empty string or its neighbour; the
\uXXXXescape wins (references/non-printing-characters.md). - Writing the older API a newer one replaces, for compatibility — the app supports the current release of each browser engine only, and a global one of them lacks is polyfilled rather than avoided (
openspec/specs/architecture/polyfills/design.md).
Deep dives
references/enums.md— when declaring an enum, its Zod schema, its values array, or a ref that holds one.references/non-printing-characters.md— when a non-printing character is about to be written into a literal.references/collections.md— when mutating an array, reading-or-inserting on aMap, choosing aSetover.some(), or narrowing what a.filter()returns.references/type-modelling.md— when reaching for a cast, re-declaring fields a source/SDK type already has, dispatching per variant, aNuxtConfigkey the compiler can't see, or a TS2590 the compiler cannot represent.references/control-flow.md— when writing or reshaping a guard, anif/else, or a chain.references/function-signatures.md— when writing a function's parameters: overloads, an options object, a default, or a boolean flag.references/floating-promises.md— when a lint error flags a floating promise, or an async function must be called from a sync slot.references/dynamic-imports.md— when reaching forawait import(...), or a dependency's docs mentionoptimizeDeps.references/class-fields.md— when adding a field to a class.references/loops.md— when writing a loop: an index-basedforthat stays, binding-position destructuring, bounding a zip.references/cloning.md— when copying an object or a class instance, deeply or with overrides.references/callbacks.md— when handing a function to an array method, a hook or a listener.
Core Rules
strictmode, with oxlint's type-aware rules on (typeAwareinoxlint.config.ts); the banned forms (any,!,==) are the lint rules' own. For!, a guard clause or optional chaining beats a cast, and a field with no initializer takesdeclare(references/class-fields.md).Omit→Exceptfromtype-fest, enforced by oxlinttypescript/no-restricted-types. Import it fromtype-festdirectly; it is not re-exported from@esposter/shared.- No parameter properties — never
constructor(private readonly foo: T). Declare fields explicitly and assign in the body. private→ ECMAScript#(no-restricted-syntaxinpackages/configuration/eslint/typescriptRules.js). Keepreadonlywhen converting (private readonly foo→readonly #foo);protectedstays, as#is inaccessible to subclasses..forEach()is banned — usefor...of(references/loops.md);unicorn/no-array-for-eachenforces it in script,vue/no-restricted-syntaxin templates.typealiases for object shapes →interface(consistent-type-definitions).- The newest platform API, always, and the form it replaces is banned — every
unicorn/prefer-*rule is on repo-wide for that reason, so a legacy form fails lint rather than review; one no rule decides is a finding, and a new replacement earns ano-restricted-syntaxentry the first time it is re-found. - Non-mutating array methods, enforced —
sort(),reverse()andsplice()are lint errors; writetoSorted/toReversed/toSplicedand assign the result back (references/collections.md). - Never hand-roll read-or-insert on a
Map—getOrCreate(map, key, () => new Set())from@esposter/shared(references/collections.md). - Never declare what nothing uses — every export (schema, type, constant, pluralized enum array) earns its existence with a call site; no speculative API. When removing the last consumer of an export, cascade-delete the newly orphaned export and its now-unused imports too.
- No
current*caching of.valuejust to use it once. If narrowing is needed after a guard, assign a descriptive name (const selectedFile = file.value) —no-restricted-syntaxrefuses acurrent*capture. Prefer plainconstovercomputed()when the source is already non-reactive (e.g. areadonlyprop field). - Cloning plain data is
structuredClone; a class instance is rebuilt through its constructor, since neither a clone nor a spread keeps its prototype (references/cloning.md). - Boolean casting — never
!!; alwaysBoolean(value). - Interpolation coerces —
${x}, never${x.toString()};no-restricted-syntaxenforces it (a radixtoString(16)stays).String(x)inside a template is only for the typesrestrict-template-expressionsrejects (unknown,symbol). - Regex — literals for static patterns,
new RegExp(template, flags)only when the pattern interpolates, and always theuflag; all three are lint errors otherwise (prefer-regex-literals,require-unicode-regexp). Naming (_REGEX) is thenamingskill's rule ("Constants"). - A non-printing character is written as its
\uXXXXescape, never the raw byte (scripts/src/workspace/controlCharacters.test.ts,references/non-printing-characters.md). - Prefer the shortened assignment forms — compound (
x += y,x ??= y) overx = x + y, chained (a.value = b.value = value) over repeating the right-hand side.restrict-plus-operandsandno-multi-assignare off for exactly this reason: a cast to silence a lint rule is strictly worse than the operator it replaces. as unknown as Tisanywith extra steps — ano-restricted-syntaxerror in source (restrictedSourceSyntaxes.js; a suite's fakes are exempt), so a surviving one is a disable naming what the compiler cannot see — never "this is safe"; the seams that earn one arereferences/type-modelling.md.- A compiler limit (TS2590) is a tagged
@ts-expect-errorin place, not a redesign (references/type-modelling.md). - Never
Object.values(SomeEnum)inline, and never abbreviate an enum value name (Configuration, notConfig). - A union of string literals is an enum —
"delete" | "get"becomesenum HttpMethodin its own model file and the annotation names it, enforced repo-wide byliteral-union/no-string-literal-union; the unions that are not sets, and the Nuxt-configurationas constexception, arereferences/enums.md. - Track selections by stable ID, not name or index — names change, indices shift on delete/reorder. Use
entity.id(UUID) as the key for selected/active items. A stale ID is harmless; a stale name/index is a bug.
Functions
- Always arrow functions —
const fn = () => { ... }. Thefunctionkeyword is only for cases wherethisbinding is required: class methods, object methods referencingthis, generators (function*). Everything else (module-level, composables, callbacks, helpers) must be an arrow function — except a helper that takes a mod's$and is imported from a sibling file, which is afunctiondeclaration (theclaude-modsskill). - A bare function reference handed to an array method is wrapped —
no-restricted-syntaxinpackages/configuration/eslint/restrictedSyntaxes.jsenforces it; the why, the hooks and listeners, and theNumber/String/Booleanexception arereferences/callbacks.md. - Prefer inferred return types — annotate only a narrower contract or a public API boundary (
references/function-signatures.md). - Syntax is never extracted into a helper — the
over-engineeringskill (references/syntax-extraction.md). - Use
RegExp.escape(value)when embedding a string in a regular expression — do not hand-roll a localescapeRegExphelper; ESLint enforces the named-helper ban inrestrictedSyntaxes.
Promise Style
try,.then/.catch/.finallyandnew Errorare banned — theerror-handlingskill owns all three and their exceptions.- Never
await import(...)for code-splitting — always a static top-levelimport, enforced byno-restricted-syntax. An exception is a disable directive whose reason is the commentreferences/dynamic-imports.mdasks for. void asyncFn()is banned (no-void) — it silencesno-floating-promisesby discarding the promise, so rejections go unhandled and the caller cannot await completion. The replacement ladder (make the callerasync, widen the callback toPromisable<void>,getSynchronizedFunctionas the last resort) isreferences/floating-promises.md.
Control Flow
- Guard clauses first, one guard per outcome, and a chain runs
if/else if/elsefrom its first branch to its last — a balancedif/elsestays balanced, and no negated test sits before a terminalelse(no-negated-condition). Where each shape stops being right:references/control-flow.md. switchfor type-based branching, every one on an enum ending indefault: exhaustiveGuard(value); per-case logic is a map keyed by the discriminant (references/control-flow.md).- Use
.includes()for 2+ equality checks —[A, B].includes(x)notx === A || x === B. Extract to a named constant only if reused.
Loops and Iteration
for...ofwith.entries()when the index is needed;Array.from(iterable, mapFn)over[...iterable].map(mapFn)—no-restricted-syntaxfails the single-spread shape. When an index-basedforstays, destructuring in the binding position, and bounding a zip:references/loops.md.
Environment Checks
- Never
import.meta.devorimport.meta.env.MODEdirectly (no-restricted-syntax) —IS_PRODUCTION/IS_DEVELOPMENTfrom#shared/util/environment/constants, the one file that reads them.
Absent Values
""as the absent string,?:over| undefinedandnullonly at a boundary are theabsent-valuesskill's.