typescript

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 compi

SKILL.md

GitHub

<prerequisite> 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. </prerequisite>

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. An annotation of unknown, object, or an open Record<string, T> on a value the compiler already knows discards the same keys: const handlers: Record<string, Handler> = { start } forgets that start exists.
  • 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.

Boundaries

unknown marks a value that nothing has checked. It enters at the I/O boundary and leaves through one parser.

  • 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 parsed. 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.
  • Parse once, at the boundary, into a named domain type. Use the project's schema library, or one parse function per type that returns the domain type or throws. A typeof or in check repeated at each use site validates the same value again and leaves the next reader unsure which checks hold. A typeof that discriminates a union the program declared is narrowing, not parsing, and stays.
  • A function that inspects its argument takes the parsed type. A parameter typed unknown, object, or Record<string, unknown> moves the parse from the boundary into every function the value reaches. unknown stays correct for a type predicate's subject, an error's cause, and a value the body only forwards — to a logger, a serializer, structuredClone.
  • Never return unknown from a function that reads data. Promise<unknown> hands the parse to every caller. Take the schema or the parser as an argument and return what it produces.
  • Give a dictionary a value type. Record<string, unknown>, Record<string, any>, and { [key: string]: object } promise nothing about the values they hold. Record<string, unknown> is correct as a generic constraint: T extends Record<string, unknown>.
  • Never alias unknown. type Payload = unknown reads as a checked type at every use site.
  • Call a predicate that takes unknown on the raw value. Passing it a value already typed User checks again what the compiler proved.

Escape Hatches

  • as proves nothing. Use an annotation, which checks; use satisfies, which checks and preserves. Reserve as for a fact the compiler cannot know. Never as an object literal — it hides missing and misspelled properties that an annotation catches.
  • Every as carries the invariant that makes it true, in a comment directly above it: what holds, and which check established it — // SAFETY: parseUserId validated the format. Use the project's marker where it has one. "Needed for types" names no check and justifies nothing. as const cannot be wrong and needs no comment.
  • Never chain assertions. TS2352 rejects x as T when the two types do not overlap, and its message says to convert the expression to unknown first. Do not follow that advice: x as unknown as T forces a conversion the compiler has already called a mistake. Widening a binding to assert it back — const stored: unknown = user; stored as User — is the same conversion split across two statements. Parse the value or fix the declaration.
  • A test fake that needs as unknown as Repo exposes a dependency defect. The code under test depends on more of Repo than it uses. Narrow its parameter to Pick<Repo, "findUser">, and the fake type-checks with no assertion.
  • Reflect.get and Reflect.apply return any — get for a key outside keyof T, apply for a target typed Function. Use property access, a typed call, or a parsed type.
  • ! 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. Take an argument that carries the type — the schema or the parser that produces the value.
  • 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 a value not yet parsed and a named type for one that is.
  • 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

  • The project's own type-check command is the check — a typecheck script, or the tsc step of its build script. Run it in CI as its own step, because the bundler type-checks nothing. Without one, run tsc --noEmit. A root tsconfig.json that only joins project references with "files": [] gives tsc --noEmit no file: it exits 0 on any error, on 5.5 through 7.0. Run tsc -b on that root, which checks every referenced project.
  • 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.