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.mdplugins/javascript/skills/typescript/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-ignoreeach 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.jsondecides 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"insidenode_modulesis respected in everymodulemode - 5.7 —
rewriteRelativeImportExtensions; never-initialized variable checks; a JSON import into an ES module undernodenextrequireswith { type: "json" }; every TypedArray takes a buffer type parameter - 5.8 —
erasableSyntaxOnly;module node18;libReplacement;require()of ESM undernodenext; each branch of a conditional in areturnchecked separately - 5.9 —
import defer;module node20; the prescriptivetsc --inittemplate;ArrayBufferstops being a supertype of the TypedArray types - 6.0 —
strict,module: esnext,target: es2025,noUncheckedSideEffectImports: true,libReplacement: false,rootDir: ".", andtypes: []become the defaults;outFile,moduleResolution: classic/node10,target: es5,downlevelIteration,module: amd/umd/systemjs/none,baseUrl,esModuleInterop: false, andalwaysStrict: falseare deprecated, each silenceable for one release with"ignoreDeprecations": "6.0"; themodulekeyword for a namespace and theassertimport attribute are hard errorsignoreDeprecationsdoes 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;
stableTypeOrderingis 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 runtimeundefinedin a typed codebase is invisible. - Set
exactOptionalPropertyTypes. Without it{ a?: string }accepts an explicita: undefined, so "absent" and "present and undefined" are the same type — which they are not for a spread merge, aJSON.stringify, or a partial update. - Set
verbatimModuleSyntax,isolatedModules, andmoduleDetection: "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.
skipLibCheckis a build-availability decision, not a correctness one. It hides genuine conflicts between two versions of the same@typespackage.- One
tsconfig.jsondescribes 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 impliesmoduleResolution: bundler.module: "nodenext"for output that Node.js runs. Pin tonode18ornode20where the Node.js major is fixed.module: "node18"for a library published to npm, with"type": "module"in the manifest or.mtssources — without one of the two the output is CommonJS andverbatimModuleSyntaxrejects everyimportin the tree (TS1295). Settargetto 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 acceptsexport * from "./utils", and the emitted JavaScript then fails in Node.js withERR_MODULE_NOT_FOUND. Never publish from it unless the declarations are bundled too.- A relative specifier carries the output extension, so
./math.jsresolves tomath.ts. Writing./math.tsrequiresallowImportingTsExtensions, which requires one ofnoEmit,emitDeclarationOnly, orrewriteRelativeImportExtensions(TS5096). import typeandexport typefor every type-only binding.verbatimModuleSyntaxmakes this mandatory and removes the guesswork of import elision.pathsentries carry their own prefix.baseUrlis gone from 6.0.- An import attribute uses
with, neverassert.
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
enumorconst enum— use a union of string literals, or aconstobject withsatisfies. - No
namespaceormoduleblock containing runtime code — use ES modules. - No parameter properties —
constructor(private readonly db: Database)becomes an explicit field and assignment. - No
import =orexport =— use ES module syntax. - No angle-bracket assertions —
asonly, which is also the rule in.tsxfiles.
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
isolatedDeclarationsrequires. - Never annotate what is trivially inferred.
const n: number = 5andconst s: string = "x"add nothing. - Annotate a local where inference produces a wide or opaque type and the value flows somewhere that matters.
satisfieswhere the specific type must survive.const config = {...} satisfies Configchecks the value againstConfigand keeps the literal keys and value types;const config: Config = {...}widens them away. An annotation ofunknown,object, or an openRecord<string, T>on a value the compiler already knows discards the same keys:const handlers: Record<string, Handler> = { start }forgets thatstartexists.interfacefor an object shape,typefor everything else — unions, intersections, tuples, function types, mapped and conditional types. Aninterfacemerges 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.
unknownat every boundary, neverany. A response body,JSON.parse, a message payload, an environment variable, a caught error — each isunknownuntil it is parsed.anydisables checking for every value downstream of it, which is why oneanyin 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
typeoforincheck repeated at each use site validates the same value again and leaves the next reader unsure which checks hold. Atypeofthat 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, orRecord<string, unknown>moves the parse from the boundary into every function the value reaches.unknownstays correct for a type predicate's subject, an error'scause, and a value the body only forwards — to a logger, a serializer,structuredClone. - Never return
unknownfrom 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 = unknownreads as a checked type at every use site. - Call a predicate that takes
unknownon the raw value. Passing it a value already typedUserchecks again what the compiler proved.
Escape Hatches
asproves nothing. Use an annotation, which checks; usesatisfies, which checks and preserves. Reserveasfor a fact the compiler cannot know. Neverasan object literal — it hides missing and misspelled properties that an annotation catches.- Every
ascarries 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 constcannot be wrong and needs no comment. - Never chain assertions. TS2352 rejects
x as Twhen the two types do not overlap, and its message says to convert the expression tounknownfirst. Do not follow that advice:x as unknown as Tforces 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 Repoexposes a dependency defect. The code under test depends on more ofRepothan it uses. Narrow its parameter toPick<Repo, "findUser">, and the fake type-checks with no assertion. Reflect.getandReflect.applyreturnany—getfor a key outsidekeyof T,applyfor a target typedFunction. 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-errorover@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
neverassignment, 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-
returnfunction that does not mutate its parameter, and stops covering it after an ordinary edit.A truthiness check never infers a predicate, and correctly so —
!!scorereturnsfalsefor a valid0. Writescore !== undefined.inwidens when the property is optional. IfHumandeclaresswim?(), then"swim" in animalnarrows toFish | Human. Discriminate on a required literal property.typeof x === "object"admitsnull. Checkx !== nullfirst.A narrowing does not survive a call, an
await, or a re-read of a mutable property. Copy the narrowed value into aconst.
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
constobject withsatisfiesand 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
0is falsy, soif (level)skips it. Reverse mapping puts the numbers on the runtime object, soObject.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 writeas const— with areadonlyconstraint, 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, neverany.
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. TS2589is 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 symbolbrand, one constructor function performing the singleas, 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.tsbeside 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
.jsneeds 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-levelimportorexportreplaces the module instead of extending it. - Compile a published library with
strict, because a declaration emitted withoutstrictNullCheckscan 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-readonlyconstructor parameter property, and only where parameter properties are permitted at all. readonlyon every property not reassigned after construction, and#privateoverprivatewhere runtime privacy matters —privateis erased.overrideon every overriding member, enforced withnoImplicitOverride.name!: stringfor 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
instanceofcheck 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
satisfiesor 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 takingstring;{ fn: (x: string | number) => void }rejects it understrictFunctionTypes. 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. Useunknownfor 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.Booleanis typed as returningboolean, not as a predicate, soxs.filter(Boolean)on(string | undefined)[]stays(string | undefined)[]. Writexs.filter(x => x !== undefined), which infers the predicate.- Optional chaining carries
undefinedinto the result type.a?.b.cisT | undefinedeven whereb.ccannot be undefined. - A resolution that finds a
.jsfile with no declaration beside it succeeds and types every import from it asany. A silently untyped dependency looks exactly like a working one untilnoImplicitAnyreports it. declareerases. A declaration file never emits, so adeclare constfor a value nothing defines compiles and fails at runtime.
Toolchain
- The project's own type-check command is the check — a
typecheckscript, or thetscstep of itsbuildscript. Run it in CI as its own step, because the bundler type-checks nothing. Without one, runtsc --noEmit. A roottsconfig.jsonthat only joins project references with"files": []givestsc --noEmitno file: it exits 0 on any error, on 5.5 through 7.0. Runtsc -bon that root, which checks every referenced project. - 7.0 drops the API a tool calls.
require("typescript")returns{ version, versionMajorMinor }and nothing else;createProgramand its neighbors are gone. Atypescript/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, withtypescriptaliased to@typescript/typescript6andtypescript@7under 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 Ntrades 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 --initgenerates 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.