TypeScript Conventions
SkillDev toolsEsposter TypeScript conventions — banned patterns (Omit over Except, forEach, parameter properties, mutating array methods, the void operator), as unknown as treated like any, arrow functions, callbacks never taking a bare function reference, regex literals, neverthrow promise style and the void ban, guard clauses and if/else-if chains, exhaustive switch guards, inferred return types, for...of loops with .entries(), Array.from over spread+map, environment constants, stable selection IDs, filter narrowing, plus deep dives on enum declaration/values arrays/refs, the "" sentinel and null-vs-undefined, modelling types instead of casting (Pick from source types, discriminant-keyed dispatch maps, nuxt.d.ts augmentation), function signatures (overloads, parameter defaults, boolean flags), the floating-promise replacement ladder, and declare over ! on class fields. Apply when writing any TypeScript in this project.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the TypeScript Conventions skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/typescript/SKILL.md and read by ahel’s review.
Deep dives
references/enums.md— when declaring an enum, its Zod schema, its values array, or a ref that holds one.references/absent-values.md— when a value can be empty or absent: a string ref, an optional field, a cursor, a nullable boundary type.references/type-modelling.md— when reaching for a cast, re-declaring fields a source/SDK type already has, dispatching per variant, or aNuxtConfigkey the compiler can't see.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/class-fields.md— when adding a field to a class.
Core Rules
strictmode +tseslint.configs.strictTypeChecked.any, non-null assertions (!), and==/!=are lint errors (no-explicit-any,no-non-null-assertion,eqeqeq) — for!prefer a guard clause or optional chaining over a cast, and a field with no initializer takesdeclarerather than!(references/class-fields.md).Omit→Exceptfromtype-fest, enforced by@typescript-eslint/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(see Loops);unicorn/no-array-for-eachenforces it in script,vue/no-restricted-syntaxin templates.typealiases for object shapes →interface(consistent-type-definitions).- Non-mutating array methods, enforced.
sort(),reverse()andsplice()are all errors — the first two from oxlint (unicorn/no-array-sort,unicorn/no-array-reverse),splicefromno-restricted-syntax, and all three restated invue/no-restricted-syntaxfor the template expressions oxlint does not read. WritetoSorted/toReversed/toSplicedand assign the result back; draining an array is taking it and putting a fresh one in its place, neversplice(0). What is left to judgement isarr.with(index, value)over[...arr.slice(0, i), value, ...arr.slice(i + 1)]. - Never hand-roll read-or-insert on a
Map—getOrCreate(map, key, () => new Set())from@esposter/shared. Both hand-rolled shapes are four lines that read as branching logic where the helper reads as one lookup: thelet x = map.get(k); if (!x) { x = …; map.set(k, x); }block, and themap.get(k) ?? []that is mutated and set back — whosesetis load-bearing only on the miss, so it looks redundant to the next reader. new Setonly for dedup — use.some()for unique arrays.Setonly when (a) deduplication is the goal, or (b) the collection is large enough that O(n).some()hurts perf.- 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.
- Named imports from libraries, but only when not auto-imported by Nuxt/modules (
ref,computed,watchfrom Vue;storeToRefsfrom Pinia; all VueUse composables are auto-imported — never import manually). - Node built-ins take the
node:protocol (unicorn/prefer-node-protocol) — but never import an ambient global:process,console,Buffer,URLandfetchare already there, so only the non-ambient built-ins are imported at all. - Never generic variable names like
parsed— use a name including the type:parsedDate,parsedResult. - No
current*caching of.valuejust to use it once. If narrowing is needed after a guard, assign a descriptive name (const selectedFile = file.value). Prefer plainconstovercomputed()when the source is already non-reactive (e.g. areadonlyprop field). - Cloning —
structuredClone(obj)for deep clones;Object.assign(structuredClone(obj), { ...updates })to clone+override. Never{ ...spread }to clone a class instance (loses prototype). Exception:structuredClone(new ClassName(...))when a plain object is explicitly required (e.g. Vjsf rejects class instances) — add a comment explaining why. - Boolean casting — never
!!; alwaysBoolean(value). - 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. - 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 — it launders a value past every check, isn't lint-enforceable, and needs a stated reason the type cannot be modelled; the default answer is that it never was. Prefer a singleas Twhere TS accepts it, and comment what the compiler cannot see — never "this is safe".- A compiler limit is a
@ts-expect-error, not a redesign. TS2590 fires where a large component instance type meets a composable's element union. Suppress in place, tagged with the code and message:// @ts-expect-error TS2590: Expression produces a union type that is too complex to represent.Never move a template ref to another element to dodge it — the directive fails the build once the error stops firing, where the workaround silently changes what the ref points at. - Never
Object.values(SomeEnum)inline, and never abbreviate an enum value name (Configuration, notConfig). - 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. - Never pass a function reference as a callback — wrap it:
array.map((item) => fn(item)),onUnmounted(() => { reset(); }). A bare reference forwards every argument the caller supplies (.mappasses the index) and losesthisbinding on a method. Applies to array methods, lifecycle hooks and event listeners alike. - Prefer inferred return types — annotate only when (a) the inferred type is too broad and you want a narrower contract (e.g.
ComputedRef<ValidationRule>instead ofComputedRef<(value: string) => string | true>), or (b) the function is a public API boundary. Never annotate for documentation, service functions included. - Don't extract helpers that add no value — if a helper just wraps an inline object literal or single expression without reuse or meaningful abstraction, use the value directly. Three lines of inline code beats a named wrapper used once.
Promise Style
-
try/catchis BANNED for fallible work — use neverthrowgetResult/getResultAsync(+withFinalizer/withFinalizerAsyncfor cleanup, nevertry/finally); never.catch()chains.new Error(...)is banned too — a throw is anInvalidOperationError, subject to the one exceptionerror-handlingnames. Both subjects in full, plusjsonDateParsefor any JSON round trip carrying dates, are the error-handling skill's. -
.then()/.catch()/.finally()are banned byno-restricted-syntax, exceptions included — the shapes that survive it, and what a disable there has to say, are the error-handling skill's. -
Never
await import(...)for code-splitting — always a static top-levelimport. The build already chunk-splits per component, so a nested dynamic import only hides the dependency and, in dev, defers Vite's discovery until first use, which can trigger a mid-session re-optimization leaving chunks on stale dep hashes. Only touchoptimizeDepswhen the dependency's own docs instruct it. Two exceptions: a library-mandated lazy-loader contract, and a heavy dependency whose only entry point is a module the app always loads — a codec behind a format map, a devtool behind a settings panel. Per-component chunking has no boundary to split those at, so the dynamic import is the split, and it says in a comment what it is keeping out of the eager graph. -
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 —
if (!condition) returnto exit early instead of wrapping the body inif. Invert and return early aggressively. - One guard per outcome, not one per condition — consecutive guards whose bodies are identical collapse into one with
||wherever the conditions are independent. Splitting them reads as though the branches differ and invites a later edit to give one its own body, which is how two conditions that must stay in lockstep drift apart. Keep them split when the bodies genuinely differ (a distinct throw, a log, a different return value), or when the second condition depends on the first having passed or has side effects —if (!a) return; if (a.b) return;throws once merged. - Do not convert balanced
if/elseinto a guard clause — guards are only correct when the remainder of the function is the single happy path. When two branches are parallel paths of similar weight, keepif/else; converting either duplicates shared steps or obscures mutual exclusivity. A reviewer suggesting "use a guard clause" is a false positive when theelsebranch contains substantial work. - Always use
if/else if/else, from the first branch to the last — no standaloneiffollowed byelse if, even when the first branch is a guard clause, and no trailing statement standing in for the finalelse. A fall-throughreturnwritten at the chain's own indent is theelsebranch with its keyword left off, and reads as code reached after the chain rather than instead of it:if (!x) return a; else if (y) return b; else return c;—no-else-returnis off for exactly this reason. Only omit the trailingelsewhen the chain has no final branch, and only omit chaining altogether when the branches are genuinely independent (different concerns, not a logical chain). - The branch before the terminal
elsemay not be negated —no-negated-conditionignores a negated test whose alternate is anotherif, soif (!x) … else if (!y) …is only legal while the chain stays open; closing it with anelsemakes the last negated test an error. Invert that test and swap the final two branches (else if (y) … else …), which is what--fixdoes — it leaves the braces and one-line bodies foroxfmtand you to settle. - Use
switchfor type-based branching — branching on an enum/discriminant with multiple cases usesswitch, not anif/else ifchain. Useif/else if/elseonly for non-enum expressions or exactly two branches. Never switch over a discriminant purely to dispatch different logic per case — key a map by the discriminant instead (references/type-modelling.md). - Every
switchon an enum or discriminated-union discriminant needsdefault: exhaustiveGuard(value)(orreturn exhaustiveGuard(value)in return-position), imported from@esposter/shared, so a new variant is a compile error. Nested switches each need their own guard. Exception: switches on non-enum values (strings, numbers, class instances). - Use
.includes()for 2+ equality checks —[A, B].includes(x)notx === A || x === B. Extract to a named constant only if reused. - No redundant type guards after a filtering condition — if a
.filter()predicate narrows the type (filter((v) => typeof v === "number")), the result is alreadynumber[]; don't add: v is numberor a cast inside the callback. Exception: a predicate passed as a function reference (filter(Boolean)) can't narrow, so a type predicate is still needed.
Loops and Iteration
Array.from(iterable, mapFn)over[...iterable].map(mapFn)for anySet/Map/non-array iterable — the two-arg form maps while converting, producing no intermediate array. AMapiterates as[key, value]with no.entries()needed:Array.from(fooMap, ([key, value]) => ({ key, value })).no-restricted-syntaxfails the single-spread shape, so what is left to a reader is the two cases where the rewrite is not the same call — a callback reading.map's third argument, and an iterator its own callback advances — both of which keep their evaluation order asArray.from(iterable).map(fn). A multi-element literal ([...a, ...b]) is a concatenation rather than a conversion and is untouched.- No index-based
for (let i = 0; i < arr.length; i++)for plain array iteration — usefor...of, and.entries()when the index is needed (for (const [i, item] of arr.entries())). The.entries()iterator cost is negligible (tiny per-element pair alloc, JIT-friendly) versus the readability win. - Index-based
forstays only when the loop genuinely isn't sequential array iteration: step counters (i += 4,i += BATCH_SIZE), pure counts (for (let i = 0; i < 3; i++)),<=bounds, multi-condition bounds, or in-body index mutation/lookahead (line.charAt(i + 1)theni++). - Destructure in the binding position (loop var, function param) straight to the props you use —
for (const [i, { id }] of files.entries()), never binding the whole object and then reading its fields. This removes a binding, so it does not conflict with the ban on a separateconst { x } = objline for a single use. Keep the whole binding only when the object is passed on whole, or used too many ways to enumerate cleanly. - Don't declare intermediate vars that are used once — inline single-use values; only name a var when it's referenced more than once or the name adds clarity.
- Bound a zip with
break, not a dual condition — iterate the driving array via.entries()andif (i >= other.length) break;.
Environment Checks
Never use import.meta.dev or import.meta.env.MODE directly — use IS_PRODUCTION/IS_DEVELOPMENT/IS_TEST from #shared/util/environment/constants:
import { IS_PRODUCTION } from "#shared/util/environment/constants";
const baseUrl = IS_PRODUCTION ? PRODUCTION_URL : DEVELOPMENT_URL;
Absent Values
ref<string>()is BANNED — app-owned strings arestringwith""as the empty sentinel, checked by truthiness, neverstring | undefined.- A property whose absent form is
undefinedis declaredfield?: T, neverfield: T | undefined(no-restricted-syntax), andundefinedis banned in app-owned code unless it carries a meaning distinct from every real value.nullis only permitted at the external system boundary (Drizzle, Azure SDK, persisted JSON blobs, a few Vuetify props) — a read that has to tell "still loading" from "loaded, no row" gates onuseQuery'sisPending, never on anullthird value (references/absent-values.md). - Full sentinel propagation rules, boundary exceptions and the enum-
Noneban:references/absent-values.md.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
- Hacker News mentions
- 20
Advanced
- Catalog kind
- skill
- Gateway key
typescript-esposter- Source
- github.com/esposter/esposter