Load the javascript skill first. Declarations, equality, closures, iteration, promise semantics, and ES module syntax
are stated there and are not repeated here. Skipping it produces TypeScript that is type-correct and idiomatically wrong.
A type earns its place by making a wrong program fail to compile. Three biases decide most calls:
- Make the invalid state unrepresentable, then let inference carry the rest. An annotation that restates what the
compiler already infers is noise; a type that admits a state the domain forbids is a defect that will be found at
runtime.
- Every escape hatch is a defect report against a type.
any, as, !, and @ts-ignore each move a failure from
the compiler to production. Reaching for one means the declaration is wrong, and that is where the fix belongs.
- The
tsconfig.json decides what the compiler catches; the pinned release decides what may be written. Read both
before writing code.
Compiler Release
The typescript entry in the project's package.json sets the release. Read it before writing code and never reach for
a feature or an option above it. Releases below 5.5 are not indexed here; a rule naming a feature older than 5.5 carries
its version where it is named.
- 5.5 — inferred type predicates;
isolatedDeclarations; ${configDir}; regex literal syntax checking
- 5.6 —
noUncheckedSideEffectImports; noCheck; strictBuiltinIteratorReturn; always-truthy and always-nullish
checks become errors; package.json "type" inside node_modules is respected in every module mode
- 5.7 —
rewriteRelativeImportExtensions; never-initialized variable checks; a JSON import into an ES module under
nodenext requires with { type: "json" }; every TypedArray takes a buffer type parameter
- 5.8 —
erasableSyntaxOnly; module node18; libReplacement; require() of ESM under nodenext; each branch of
a conditional in a return checked separately
- 5.9 —
import defer; module node20; the prescriptive tsc --init template; ArrayBuffer stops being a
supertype of the TypedArray types
- 6.0 —
strict, module: esnext, target: es2025, noUncheckedSideEffectImports: true, libReplacement: false,
rootDir: ".", and types: [] become the defaults; outFile, moduleResolution: classic/node10, target: es5,
downlevelIteration, module: amd/umd/systemjs/none, baseUrl, esModuleInterop: false, and
alwaysStrict: false are deprecated, each silenceable for one release with "ignoreDeprecations": "6.0"; the
module keyword for a namespace and the assert import attribute are hard errors ignoreDeprecations does not
reach; stableTypeOrdering; subpath imports starting #/
- 7.0 — the compiler is a native Go binary; every 6.0 deprecation becomes a removal; the 6.0 programmatic API is
gone;
stableTypeOrdering is fixed on; --checkers, --builders, --singleThreaded; template-literal inference
consumes whole Unicode code points
Read [${CLAUDE_SKILL_DIR}/references/versions/ts-N.N.md] — one file per release, ts-5.5.md through ts-7.0.md —
when writing against a feature near the pinned release, and whenever raising it. Each carries what its release added,
which existing behavior it changed, and the traps it introduced.
A 6.0 or 7.0 upgrade is a configuration change before it is a code change. Set rootDir and types explicitly
before anything else. A rootDir left to the new default moves output into dist/src/ when the config sits above the
sources, and reports TS5011 naming the directory it wants. types is the one that fails silently: its [] default
drops every global declaration until the packages are listed, and the symptom is a burst of Cannot find name errors
that say nothing about the option that caused them.
Configuration
strict: true is the floor, not the target. It leaves off the two checks that catch the most remaining runtime
failures.
- Set
noUncheckedIndexedAccess. Without it an index read of an array or a record is typed as present, and the most
common runtime undefined in a typed codebase is invisible.
- Set
exactOptionalPropertyTypes. Without it { a?: string } accepts an explicit a: undefined, so "absent" and
"present and undefined" are the same type — which they are not for a spread merge, a JSON.stringify, or a partial
update.
- Set
verbatimModuleSyntax, isolatedModules, and moduleDetection: "force" in any project a bundler or a
stripping runtime consumes.
- Never turn a strict-family flag off to make an error go away. Fix the type or suppress the single line.
skipLibCheck is a build-availability decision, not a correctness one. It hides genuine conflicts between two
versions of the same @types package.
- One
tsconfig.json describes one environment. Server, DOM, worker, and test code each need their own, joined by
project references. A single config covering several is wrong about the globals in at least one of them.
- Adopt one flag per change, each landing green. Turning on several at once produces an error count nobody triages.
Read [${CLAUDE_SKILL_DIR}/references/conventions/strictness.md] when choosing which checks a project runs, when
planning an adoption order for an existing codebase, or when deciding whether a suppression is justified — it carries
every flag in and outside the strict family with the defect class each one catches and what it costs.
Modules
The compiler models what the runtime or the bundler does to the output files, then maps back to source for types.
Every rule here follows from that.
module: "preserve" for anything a bundler or Bun consumes; it implies moduleResolution: bundler.
module: "nodenext" for output that Node.js runs. Pin to node18 or node20 where the Node.js major is fixed.
module: "node18" for a library published to npm, with "type": "module" in the manifest or .mts sources —
without one of the two the output is CommonJS and verbatimModuleSyntax rejects every import in the tree
(TS1295). Set target to the lowest ECMAScript version supported, ES2015 being the floor from 6.0. Node.js
resolution is the strictest host; satisfying it satisfies the bundlers.
moduleResolution: "bundler" is infectious. It accepts export * from "./utils", and the emitted JavaScript then
fails in Node.js with ERR_MODULE_NOT_FOUND. Never publish from it unless the declarations are bundled too.
- A relative specifier carries the output extension, so
./math.js resolves to math.ts. Writing ./math.ts
requires allowImportingTsExtensions, which requires one of noEmit, emitDeclarationOnly, or
rewriteRelativeImportExtensions (TS5096).
import type and export type for every type-only binding. verbatimModuleSyntax makes this mandatory and
removes the guesswork of import elision.
paths entries carry their own prefix. baseUrl is gone from 6.0.
- An import attribute uses
with, never assert.
Read [${CLAUDE_SKILL_DIR}/references/conventions/modules.md] when setting up a project, choosing module and
moduleResolution for a given shape, or debugging a resolution failure — it carries the per-shape configurations, the
format-detection rules, and the exports-condition layout for publishing types.
Erasable Syntax
Where the source runs unbuilt — Node.js type-stripping, Bun, tsx, esbuild — TypeScript syntax with runtime behavior is
not erasable and fails at load. erasableSyntaxOnly moves the failure to compile time under TS1294.
- No
enum or const enum — use a union of string literals, or a const object with satisfies.
- No
namespace or module block containing runtime code — use ES modules.
- No parameter properties —
constructor(private readonly db: Database) becomes an explicit field and assignment.
- No
import = or export = — use ES module syntax.
- No angle-bracket assertions —
as only, which is also the rule in .tsx files.
The ambient forms stay legal: declare enum and a declare namespace holding only declarations emit nothing.
Annotations
- Annotate every exported signature — parameters and return type. It pins the contract, stops a body edit from
silently widening the public API, produces the error at the definition rather than at a distant call, and is what
isolatedDeclarations requires.
- Never annotate what is trivially inferred.
const n: number = 5 and const s: string = "x" add nothing.
- Annotate a local where inference produces a wide or opaque type and the value flows somewhere that matters.
satisfies where the specific type must survive. const config = {...} satisfies Config checks the value against
Config and keeps the literal keys and value types; const config: Config = {...} widens them away.
interface for an object shape, type for everything else — unions, intersections, tuples, function types,
mapped and conditional types. An interface merges across declarations, which is what makes a public surface
augmentable and what makes an accidental redeclaration silent.
Escape Hatches
unknown at every boundary, never any. A response body, JSON.parse, a message payload, an environment
variable, a caught error — each is unknown until it is narrowed. any disables checking for every value downstream
of it, which is why one any in a call chain hides a class of bugs rather than one.
as proves nothing. Use an annotation, which checks; use satisfies, which checks and preserves. Reserve as
for a fact the compiler cannot know, on one line, with the reason beside it. Never as an object literal — it hides
missing and misspelled properties that an annotation catches.
- Never double-assert through
any. Where a double assertion is genuinely needed, go through unknown.
! only where the invariant is stated in the same function. Otherwise narrow, or make the type honest.
@ts-expect-error over @ts-ignore, with the reason on the same line. The first fails once the error it
suppresses is gone, so it cannot outlive its cause.
- Three suppressions of the same shape is a declaration defect. Fix the type.
Narrowing
Discriminated unions over optional-property bags. { ok: true; data: T } | { ok: false; error: E } makes the two
impossible states unrepresentable; { ok: boolean; data?: T; error?: E } permits both and forces a check at every
read.
Close every exhaustive switch with a never assignment, so adding a union member breaks the build at each site
rather than falling through at runtime:
default: {
const unhandled: never = shape;
return unhandled;
}
Write the type predicate explicitly on an exported guard. Inference (5.5) covers a one-return function that does
not mutate its parameter, and stops covering it after an ordinary edit.
A truthiness check never infers a predicate, and correctly so — !!score returns false for a valid 0. Write
score !== undefined.
in widens when the property is optional. If Human declares swim?(), then "swim" in animal narrows to
Fish | Human. Discriminate on a required literal property.
typeof x === "object" admits null. Check x !== null first.
A narrowing does not survive a call, an await, or a re-read of a mutable property. Copy the narrowed value into
a const.
Read [${CLAUDE_SKILL_DIR}/references/conventions/narrowing.md] when a narrowing does not hold and the obvious fix does
not work, or when designing a union that several call sites will discriminate — it carries the control-flow rules, the
predicate and assertion-function contracts, and each gotcha with its mechanism.
Enums
A union of string literals replaces an enum. type Status = "active" | "inactive" is erasable, needs no import at
the use site, and narrows without a runtime object.
Where a runtime object is genuinely needed — iteration, reverse lookup, a namespace for related constants — use a
const object with satisfies and derive the type:
const Status = { Active: "active", Inactive: "inactive" } as const satisfies Record<string, string>;
type Status = (typeof Status)[keyof typeof Status];
Never a numeric enum. The member with value 0 is falsy, so if (level) skips it. Reverse mapping puts the
numbers on the runtime object, so Object.keys(Direction) returns ["0", "1", "A", "B"]. And the emitted value
carries no meaning in a log or a serialized payload.
Generics
- A type parameter that appears once in a signature is a wider type in disguise. Delete it.
- A type parameter that appears only in the return type cannot be inferred, so the caller guesses and the function
performs an unchecked assertion on their behalf. Return
unknown, or take an argument that carries the type.
- Constrain by what the body uses, and relate parameters through the constraint:
<T, K extends keyof T>.
const T (5.0) instead of asking callers to write as const — with a readonly constraint, because a mutable
constraint makes inference fall back to it silently.
NoInfer<T> (5.4) where one argument is the source of truth and another must be validated against it.
- Prefer a union parameter to overloads where the logic is one path, and optional parameters to overloads where the
signatures differ only in trailing arguments. Where overloads are unavoidable, order specific before general — the
first match wins.
- A callback parameter is never optional, and a discarded callback result is
void, never any.
Read [${CLAUDE_SKILL_DIR}/references/conventions/generics.md] when a call refuses to infer what it obviously should,
or when designing a generic API surface — it carries the inference-control tools with their failure modes and the
satisfies-versus-annotation-versus-assertion split.
Type-Level Programming
- Stay at the lowest tier that solves the problem: named types, then the built-in utility types, then conditional
and mapped types, and only then recursion. Each tier up costs readability, error-message quality, and check time.
- A type whose purpose does not fit in one sentence is over budget. Split it into named aliases a reader can hover.
- A naked type parameter distributes over a union. Wrap both sides in a tuple —
[T] extends [U] — where the
question is about the union as a whole.
TS2589 is the instantiation ceiling, not a diagnosis. A tail-recursive conditional reaches 1000 instantiations
and reports it past that; one that wraps its recursive call stops far earlier, under 50 where the accumulator is a
tuple spread. Large unions and deep object types reach the same error by another route. Where recursion is the cause,
rewrite with an accumulator so the recursive call is the final operation.
- Brand a type only where a wrong value of the same primitive would corrupt something — an identifier that could be
swapped with another identifier, a validated string, a unit-bearing number. One
unique symbol brand, one constructor
function performing the single as, and no other construction site.
Read [${CLAUDE_SKILL_DIR}/references/conventions/type-level.md] when writing a conditional, mapped, or
template-literal type, or when a type-level construct is slow or hits a depth limit — it carries distributivity, key
remapping, the modifier operators, the recursion ceilings, and the branding mechanisms with their tradeoffs.
Declarations
- Emit declarations, never hand-write them for TypeScript source. A hand-written
.d.ts beside compiled output
drifts with no error anywhere, because the compiler treats a declaration file as proof rather than as a claim to
check.
- A "has or is using private name" error is a missing export or a missing annotation, not a compiler limitation.
Annotate the exported signature so the emitted declaration does not depend on what the body infers.
- Every emitted
.js needs a sibling declaration with the matching extension — .d.ts/.js, .d.mts/.mjs,
.d.cts/.cjs. A dual-format package shipping one declaration tree produces errors only in consumers.
- Augment a module from inside a module file. A
declare module "x" in a file with no top-level import or
export replaces the module instead of extending it.
- Compile a published library with
strict, because a declaration emitted without strictNullChecks can be an
error in a consumer's strict project.
Read [${CLAUDE_SKILL_DIR}/references/conventions/declarations.md] when declaration emit fails, when adopting
isolatedDeclarations, or when preparing a package for publication — it carries the emit blockers with their fixes, the
.d.ts authoring rules, and the publishing checklist.
Classes
- Omit
public; it is the default. Write it only on a non-readonly constructor parameter property, and only where
parameter properties are permitted at all.
readonly on every property not reassigned after construction, and #private over private where runtime
privacy matters — private is erased.
override on every overriding member, enforced with noImplicitOverride.
name!: string for a field a framework assigns, and nowhere else. It is a promise the compiler cannot check.
- Use an interface for a data shape. A class used only to carry data adds a constructor, a prototype, and an
instanceof check that nothing needs.
Gotchas
- Excess-property checking applies only to a fresh object literal. Assigning the same object through a variable
passes silently. This is why
satisfies or a direct annotation catches a typo that a two-step assignment does not.
- Method syntax is bivariant; property syntax is not.
{ fn(x: string | number): void } accepts an implementation
taking string; { fn: (x: string | number) => void } rejects it under strictFunctionTypes. Declare a callback as
a property when the parameter type must be checked.
- An empty interface and
{} accept almost everything. {} is every non-nullish value, primitives included. Use
unknown for opaque, object for non-primitive, Record<string, unknown> for a dictionary.
- A type predicate is unchecked. The compiler verifies the returned type is assignable to the parameter type, not
that the body proves it. A wrong predicate lies to every caller.
filter(Boolean) does not narrow. Boolean is typed as returning boolean, not as a predicate, so
xs.filter(Boolean) on (string | undefined)[] stays (string | undefined)[]. Write
xs.filter(x => x !== undefined), which infers the predicate.
- Optional chaining carries
undefined into the result type. a?.b.c is T | undefined even where b.c cannot be
undefined.
- A resolution that finds a
.js file with no declaration beside it succeeds and types every import from it as
any. A silently untyped dependency looks exactly like a working one until noImplicitAny reports it.
declare erases. A declaration file never emits, so a declare const for a value nothing defines compiles and
fails at runtime.
Toolchain
tsc --noEmit is the check. Wire it into CI separately from the bundler, which type-checks nothing.
- 7.0 drops the API a tool calls.
require("typescript") returns { version, versionMajorMinor } and nothing else;
createProgram and its neighbors are gone. A typescript/unstable/* family ships beside it, and the compiler team
states the expectation that 7.1 carries a stable replacement. Anything that embeds the compiler — typescript-eslint,
Volar for Vue, Svelte, Astro and MDX, Angular template checking — needs the 6.0 API until then. Install both, with
typescript aliased to @typescript/typescript6 and typescript@7 under a second name, and let the tool resolve the
one it needs. typescript-eslint 8.69.0 declares a peer range of >=4.8.4 <6.1.0, which excludes 7.0.
--checkers N trades memory for speed in 7.0 and defaults to 4. Pin one value across CI and local runs, because
varying it can surface order-dependent results.
tsc --init generates the baseline the compiler team recommends for the installed release. Start from its output
rather than from a remembered template.
Application
When writing TypeScript, apply these conventions silently — do not narrate a rule while following it. Where existing
code contradicts one, follow the codebase and flag the divergence once.
When reviewing TypeScript, cite the violation and show the fix inline. Do not lecture.
Bad: "TypeScript best practices recommend avoiding type assertions here because..."
Good: const u = res.data as User -> const u: User = parseUser(res.data)
Integration
The javascript skill is a hard prerequisite and states everything a type does not change — declarations, equality,
closures, iteration, promise semantics, ES module syntax, and LSP navigation. This skill never restates it.
This skill owns which TypeScript syntax is erasable and what each compiler option does. The nodejs and bun
skills own their own stripping switches and the syntax their runtimes refuse. The vitest skill owns expectTypeOf
and the typecheck options, which are its API and its config keys. The coding skill governs workflow.
When in doubt, make the type narrower and the escape hatch smaller.
1---2name: typescript3description: Write and review TypeScript: the type system, strictness configuration, narrowing, generics, type-level patterns, declaration files, module resolution and `tsconfig`, and the release a project's compiler pins.4---56<prerequisite>7Load the `javascript` skill first. Declarations, equality, closures, iteration, promise semantics, and ES module syntax8are stated there and are not repeated here. Skipping it produces TypeScript that is type-correct and idiomatically wrong.9</prerequisite>1011A type earns its place by making a wrong program fail to compile. Three biases decide most calls:1213- **Make the invalid state unrepresentable, then let inference carry the rest.** An annotation that restates what the14 compiler already infers is noise; a type that admits a state the domain forbids is a defect that will be found at15 runtime.16- **Every escape hatch is a defect report against a type.** `any`, `as`, `!`, and `@ts-ignore` each move a failure from17 the compiler to production. Reaching for one means the declaration is wrong, and that is where the fix belongs.18- **The `tsconfig.json` decides what the compiler catches; the pinned release decides what may be written.** Read both19 before writing code.2021## Compiler Release2223The `typescript` entry in the project's `package.json` sets the release. Read it before writing code and never reach for24a feature or an option above it. Releases below 5.5 are not indexed here; a rule naming a feature older than 5.5 carries25its version where it is named.2627- **5.5** — inferred type predicates; `isolatedDeclarations`; `${configDir}`; regex literal syntax checking28- **5.6** — `noUncheckedSideEffectImports`; `noCheck`; `strictBuiltinIteratorReturn`; always-truthy and always-nullish29 checks become errors; `package.json` `"type"` inside `node_modules` is respected in every `module` mode30- **5.7** — `rewriteRelativeImportExtensions`; never-initialized variable checks; a JSON import into an ES module under31 `nodenext` requires `with { type: "json" }`; every TypedArray takes a buffer type parameter32- **5.8** — `erasableSyntaxOnly`; `module node18`; `libReplacement`; `require()` of ESM under `nodenext`; each branch of33 a conditional in a `return` checked separately34- **5.9** — `import defer`; `module node20`; the prescriptive `tsc --init` template; `ArrayBuffer` stops being a35 supertype of the TypedArray types36- **6.0** — `strict`, `module: esnext`, `target: es2025`, `noUncheckedSideEffectImports: true`, `libReplacement: false`,37 `rootDir: "."`, and `types: []` become the defaults; `outFile`, `moduleResolution: classic`/`node10`, `target: es5`,38 `downlevelIteration`, `module: amd`/`umd`/`systemjs`/`none`, `baseUrl`, `esModuleInterop: false`, and39 `alwaysStrict: false` are deprecated, each silenceable for one release with `"ignoreDeprecations": "6.0"`; the40 `module` keyword for a namespace and the `assert` import attribute are hard errors `ignoreDeprecations` does not41 reach; `stableTypeOrdering`; subpath imports starting `#/`42- **7.0** — the compiler is a native Go binary; every 6.0 deprecation becomes a removal; the 6.0 programmatic API is43 gone; `stableTypeOrdering` is fixed on; `--checkers`, `--builders`, `--singleThreaded`; template-literal inference44 consumes whole Unicode code points4546Read [`${CLAUDE_SKILL_DIR}/references/versions/ts-N.N.md`] — one file per release, `ts-5.5.md` through `ts-7.0.md` —47when writing against a feature near the pinned release, and whenever raising it. Each carries what its release added,48which existing behavior it changed, and the traps it introduced.4950**A 6.0 or 7.0 upgrade is a configuration change before it is a code change.** Set `rootDir` and `types` explicitly51before anything else. A `rootDir` left to the new default moves output into `dist/src/` when the config sits above the52sources, and reports `TS5011` naming the directory it wants. `types` is the one that fails silently: its `[]` default53drops every global declaration until the packages are listed, and the symptom is a burst of `Cannot find name` errors54that say nothing about the option that caused them.5556## Configuration5758`strict: true` is the floor, not the target. It leaves off the two checks that catch the most remaining runtime59failures.6061- **Set `noUncheckedIndexedAccess`.** Without it an index read of an array or a record is typed as present, and the most62 common runtime `undefined` in a typed codebase is invisible.63- **Set `exactOptionalPropertyTypes`.** Without it `{ a?: string }` accepts an explicit `a: undefined`, so "absent" and64 "present and undefined" are the same type — which they are not for a spread merge, a `JSON.stringify`, or a partial65 update.66- **Set `verbatimModuleSyntax`, `isolatedModules`, and `moduleDetection: "force"`** in any project a bundler or a67 stripping runtime consumes.68- **Never turn a strict-family flag off to make an error go away.** Fix the type or suppress the single line.69- **`skipLibCheck` is a build-availability decision, not a correctness one.** It hides genuine conflicts between two70 versions of the same `@types` package.71- **One `tsconfig.json` describes one environment.** Server, DOM, worker, and test code each need their own, joined by72 project references. A single config covering several is wrong about the globals in at least one of them.73- **Adopt one flag per change, each landing green.** Turning on several at once produces an error count nobody triages.7475Read [`${CLAUDE_SKILL_DIR}/references/conventions/strictness.md`] when choosing which checks a project runs, when76planning an adoption order for an existing codebase, or when deciding whether a suppression is justified — it carries77every flag in and outside the `strict` family with the defect class each one catches and what it costs.7879## Modules8081The compiler models what the runtime or the bundler does to the **output** files, then maps back to source for types.82Every rule here follows from that.8384- **`module: "preserve"` for anything a bundler or Bun consumes**; it implies `moduleResolution: bundler`.85 **`module: "nodenext"` for output that Node.js runs.** Pin to `node18` or `node20` where the Node.js major is fixed.86- **`module: "node18"` for a library published to npm**, with `"type": "module"` in the manifest or `.mts` sources —87 without one of the two the output is CommonJS and `verbatimModuleSyntax` rejects every `import` in the tree88 (`TS1295`). Set `target` to the **lowest** ECMAScript version supported, ES2015 being the floor from 6.0. Node.js89 resolution is the strictest host; satisfying it satisfies the bundlers.90- **`moduleResolution: "bundler"` is infectious.** It accepts `export * from "./utils"`, and the emitted JavaScript then91 fails in Node.js with `ERR_MODULE_NOT_FOUND`. Never publish from it unless the declarations are bundled too.92- **A relative specifier carries the output extension**, so `./math.js` resolves to `math.ts`. Writing `./math.ts`93 requires `allowImportingTsExtensions`, which requires one of `noEmit`, `emitDeclarationOnly`, or94 `rewriteRelativeImportExtensions` (`TS5096`).95- **`import type` and `export type` for every type-only binding.** `verbatimModuleSyntax` makes this mandatory and96 removes the guesswork of import elision.97- **`paths` entries carry their own prefix.** `baseUrl` is gone from 6.0.98- **An import attribute uses `with`, never `assert`.**99100Read [`${CLAUDE_SKILL_DIR}/references/conventions/modules.md`] when setting up a project, choosing `module` and101`moduleResolution` for a given shape, or debugging a resolution failure — it carries the per-shape configurations, the102format-detection rules, and the `exports`-condition layout for publishing types.103104## Erasable Syntax105106Where the source runs unbuilt — Node.js type-stripping, Bun, tsx, esbuild — TypeScript syntax with runtime behavior is107not erasable and fails at load. `erasableSyntaxOnly` moves the failure to compile time under `TS1294`.108109- **No `enum` or `const enum`** — use a union of string literals, or a `const` object with `satisfies`.110- **No `namespace` or `module` block containing runtime code** — use ES modules.111- **No parameter properties** — `constructor(private readonly db: Database)` becomes an explicit field and assignment.112- **No `import =` or `export =`** — use ES module syntax.113- **No angle-bracket assertions** — `as` only, which is also the rule in `.tsx` files.114115The ambient forms stay legal: `declare enum` and a `declare namespace` holding only declarations emit nothing.116117## Annotations118119- **Annotate every exported signature — parameters and return type.** It pins the contract, stops a body edit from120 silently widening the public API, produces the error at the definition rather than at a distant call, and is what121 `isolatedDeclarations` requires.122- **Never annotate what is trivially inferred.** `const n: number = 5` and `const s: string = "x"` add nothing.123- **Annotate a local where inference produces a wide or opaque type** and the value flows somewhere that matters.124- **`satisfies` where the specific type must survive.** `const config = {...} satisfies Config` checks the value against125 `Config` and keeps the literal keys and value types; `const config: Config = {...}` widens them away.126- **`interface` for an object shape, `type` for everything else** — unions, intersections, tuples, function types,127 mapped and conditional types. An `interface` merges across declarations, which is what makes a public surface128 augmentable and what makes an accidental redeclaration silent.129130## Escape Hatches131132- **`unknown` at every boundary, never `any`.** A response body, `JSON.parse`, a message payload, an environment133 variable, a caught error — each is `unknown` until it is narrowed. `any` disables checking for every value downstream134 of it, which is why one `any` in a call chain hides a class of bugs rather than one.135- **`as` proves nothing.** Use an annotation, which checks; use `satisfies`, which checks and preserves. Reserve `as`136 for a fact the compiler cannot know, on one line, with the reason beside it. Never `as` an object literal — it hides137 missing and misspelled properties that an annotation catches.138- **Never double-assert through `any`.** Where a double assertion is genuinely needed, go through `unknown`.139- **`!` only where the invariant is stated in the same function.** Otherwise narrow, or make the type honest.140- **`@ts-expect-error` over `@ts-ignore`**, with the reason on the same line. The first fails once the error it141 suppresses is gone, so it cannot outlive its cause.142- **Three suppressions of the same shape is a declaration defect.** Fix the type.143144## Narrowing145146- **Discriminated unions over optional-property bags.** `{ ok: true; data: T } | { ok: false; error: E }` makes the two147 impossible states unrepresentable; `{ ok: boolean; data?: T; error?: E }` permits both and forces a check at every148 read.149- **Close every exhaustive switch with a `never` assignment**, so adding a union member breaks the build at each site150 rather than falling through at runtime:151152 ```ts153 default: {154 const unhandled: never = shape;155 return unhandled;156 }157 ```158159- **Write the type predicate explicitly on an exported guard.** Inference (5.5) covers a one-`return` function that does160 not mutate its parameter, and stops covering it after an ordinary edit.161- **A truthiness check never infers a predicate**, and correctly so — `!!score` returns `false` for a valid `0`. Write162 `score !== undefined`.163- **`in` widens when the property is optional.** If `Human` declares `swim?()`, then `"swim" in animal` narrows to164 `Fish | Human`. Discriminate on a required literal property.165- **`typeof x === "object"` admits `null`.** Check `x !== null` first.166- **A narrowing does not survive a call, an `await`, or a re-read of a mutable property.** Copy the narrowed value into167 a `const`.168169Read [`${CLAUDE_SKILL_DIR}/references/conventions/narrowing.md`] when a narrowing does not hold and the obvious fix does170not work, or when designing a union that several call sites will discriminate — it carries the control-flow rules, the171predicate and assertion-function contracts, and each gotcha with its mechanism.172173## Enums174175- **A union of string literals replaces an enum.** `type Status = "active" | "inactive"` is erasable, needs no import at176 the use site, and narrows without a runtime object.177- **Where a runtime object is genuinely needed** — iteration, reverse lookup, a namespace for related constants — use a178 `const` object with `satisfies` and derive the type:179180 ```ts181 const Status = { Active: "active", Inactive: "inactive" } as const satisfies Record<string, string>;182 type Status = (typeof Status)[keyof typeof Status];183 ```184185- **Never a numeric enum.** The member with value `0` is falsy, so `if (level)` skips it. Reverse mapping puts the186 numbers on the runtime object, so `Object.keys(Direction)` returns `["0", "1", "A", "B"]`. And the emitted value187 carries no meaning in a log or a serialized payload.188189## Generics190191- **A type parameter that appears once in a signature is a wider type in disguise.** Delete it.192- **A type parameter that appears only in the return type cannot be inferred**, so the caller guesses and the function193 performs an unchecked assertion on their behalf. Return `unknown`, or take an argument that carries the type.194- **Constrain by what the body uses**, and relate parameters through the constraint: `<T, K extends keyof T>`.195- **`const T` (5.0) instead of asking callers to write `as const`** — with a `readonly` constraint, because a mutable196 constraint makes inference fall back to it silently.197- **`NoInfer<T>` (5.4) where one argument is the source of truth** and another must be validated against it.198- **Prefer a union parameter to overloads** where the logic is one path, and optional parameters to overloads where the199 signatures differ only in trailing arguments. Where overloads are unavoidable, order specific before general — the200 first match wins.201- **A callback parameter is never optional, and a discarded callback result is `void`, never `any`.**202203Read [`${CLAUDE_SKILL_DIR}/references/conventions/generics.md`] when a call refuses to infer what it obviously should,204or when designing a generic API surface — it carries the inference-control tools with their failure modes and the205`satisfies`-versus-annotation-versus-assertion split.206207## Type-Level Programming208209- **Stay at the lowest tier that solves the problem**: named types, then the built-in utility types, then conditional210 and mapped types, and only then recursion. Each tier up costs readability, error-message quality, and check time.211- **A type whose purpose does not fit in one sentence is over budget.** Split it into named aliases a reader can hover.212- **A naked type parameter distributes over a union.** Wrap both sides in a tuple — `[T] extends [U]` — where the213 question is about the union as a whole.214- **`TS2589` is the instantiation ceiling, not a diagnosis.** A tail-recursive conditional reaches 1000 instantiations215 and reports it past that; one that wraps its recursive call stops far earlier, under 50 where the accumulator is a216 tuple spread. Large unions and deep object types reach the same error by another route. Where recursion is the cause,217 rewrite with an accumulator so the recursive call is the final operation.218- **Brand a type only where a wrong value of the same primitive would corrupt something** — an identifier that could be219 swapped with another identifier, a validated string, a unit-bearing number. One `unique symbol` brand, one constructor220 function performing the single `as`, and no other construction site.221222Read [`${CLAUDE_SKILL_DIR}/references/conventions/type-level.md`] when writing a conditional, mapped, or223template-literal type, or when a type-level construct is slow or hits a depth limit — it carries distributivity, key224remapping, the modifier operators, the recursion ceilings, and the branding mechanisms with their tradeoffs.225226## Declarations227228- **Emit declarations, never hand-write them for TypeScript source.** A hand-written `.d.ts` beside compiled output229 drifts with no error anywhere, because the compiler treats a declaration file as proof rather than as a claim to230 check.231- **A "has or is using private name" error is a missing export or a missing annotation**, not a compiler limitation.232 Annotate the exported signature so the emitted declaration does not depend on what the body infers.233- **Every emitted `.js` needs a sibling declaration with the matching extension** — `.d.ts`/`.js`, `.d.mts`/`.mjs`,234 `.d.cts`/`.cjs`. A dual-format package shipping one declaration tree produces errors only in consumers.235- **Augment a module from inside a module file.** A `declare module "x"` in a file with no top-level `import` or236 `export` replaces the module instead of extending it.237- **Compile a published library with `strict`**, because a declaration emitted without `strictNullChecks` can be an238 error in a consumer's strict project.239240Read [`${CLAUDE_SKILL_DIR}/references/conventions/declarations.md`] when declaration emit fails, when adopting241`isolatedDeclarations`, or when preparing a package for publication — it carries the emit blockers with their fixes, the242`.d.ts` authoring rules, and the publishing checklist.243244## Classes245246- **Omit `public`; it is the default.** Write it only on a non-`readonly` constructor parameter property, and only where247 parameter properties are permitted at all.248- **`readonly` on every property not reassigned after construction**, and `#private` over `private` where runtime249 privacy matters — `private` is erased.250- **`override` on every overriding member**, enforced with `noImplicitOverride`.251- **`name!: string` for a field a framework assigns**, and nowhere else. It is a promise the compiler cannot check.252- **Use an interface for a data shape.** A class used only to carry data adds a constructor, a prototype, and an253 `instanceof` check that nothing needs.254255## Gotchas256257- **Excess-property checking applies only to a fresh object literal.** Assigning the same object through a variable258 passes silently. This is why `satisfies` or a direct annotation catches a typo that a two-step assignment does not.259- **Method syntax is bivariant; property syntax is not.** `{ fn(x: string | number): void }` accepts an implementation260 taking `string`; `{ fn: (x: string | number) => void }` rejects it under `strictFunctionTypes`. Declare a callback as261 a property when the parameter type must be checked.262- **An empty interface and `{}` accept almost everything.** `{}` is every non-nullish value, primitives included. Use263 `unknown` for opaque, `object` for non-primitive, `Record<string, unknown>` for a dictionary.264- **A type predicate is unchecked.** The compiler verifies the returned type is assignable to the parameter type, not265 that the body proves it. A wrong predicate lies to every caller.266- **`filter(Boolean)` does not narrow.** `Boolean` is typed as returning `boolean`, not as a predicate, so267 `xs.filter(Boolean)` on `(string | undefined)[]` stays `(string | undefined)[]`. Write268 `xs.filter(x => x !== undefined)`, which infers the predicate.269- **Optional chaining carries `undefined` into the result type.** `a?.b.c` is `T | undefined` even where `b.c` cannot be270 undefined.271- **A resolution that finds a `.js` file with no declaration beside it succeeds and types every import from it as272 `any`.** A silently untyped dependency looks exactly like a working one until `noImplicitAny` reports it.273- **`declare` erases.** A declaration file never emits, so a `declare const` for a value nothing defines compiles and274 fails at runtime.275276## Toolchain277278- **`tsc --noEmit` is the check.** Wire it into CI separately from the bundler, which type-checks nothing.279- **7.0 drops the API a tool calls.** `require("typescript")` returns `{ version, versionMajorMinor }` and nothing else;280 `createProgram` and its neighbors are gone. A `typescript/unstable/*` family ships beside it, and the compiler team281 states the expectation that 7.1 carries a stable replacement. Anything that embeds the compiler — typescript-eslint,282 Volar for Vue, Svelte, Astro and MDX, Angular template checking — needs the 6.0 API until then. Install both, with283 `typescript` aliased to `@typescript/typescript6` and `typescript@7` under a second name, and let the tool resolve the284 one it needs. typescript-eslint 8.69.0 declares a peer range of `>=4.8.4 <6.1.0`, which excludes 7.0.285- **`--checkers N` trades memory for speed** in 7.0 and defaults to 4. Pin one value across CI and local runs, because286 varying it can surface order-dependent results.287- **`tsc --init` generates the baseline the compiler team recommends for the installed release.** Start from its output288 rather than from a remembered template.289290## Application291292When **writing** TypeScript, apply these conventions silently — do not narrate a rule while following it. Where existing293code contradicts one, follow the codebase and flag the divergence once.294295When **reviewing** TypeScript, cite the violation and show the fix inline. Do not lecture.296297```298Bad: "TypeScript best practices recommend avoiding type assertions here because..."299Good: const u = res.data as User -> const u: User = parseUser(res.data)300```301302## Integration303304The **javascript** skill is a hard prerequisite and states everything a type does not change — declarations, equality,305closures, iteration, promise semantics, ES module syntax, and LSP navigation. This skill never restates it.306307This skill owns which TypeScript syntax is erasable and what each compiler option does. The **nodejs** and **bun**308skills own their own stripping switches and the syntax their runtimes refuse. The **vitest** skill owns `expectTypeOf`309and the `typecheck` options, which are its API and its config keys. The **coding** skill governs workflow.310311**When in doubt, make the type narrower and the escape hatch smaller.**