TypeScript (language skill)
This is a knowledge skill, not an agent: it loads from context whenever TypeScript or
JavaScript is written or reviewed, and the role agents (/fe, /be, /rev) route into
it for the language-level rules. Framework depth stays with /fe's stack references —
../../frontend/react/frontend-developer/SKILL.md and its
../../frontend/react/frontend-developer/references/react-expertise.md — while this file
owns TS-the-language, the Node runtime, package discipline, the lint/test toolchain, and
non-UI code: CLIs, servers, libraries, scripts.
Trigger
Use this skill when:
- Writing, editing, or reviewing
.ts,.tsx,.js, or.mjsfiles - Changing
tsconfig*.json,package.json, or a lockfile — flags, dependencies, exports, engines - Diagnosing a
tsc, ESLint, or Vitest failure - Designing a TypeScript component — module boundaries, error surface, async topology
- Planning tests for TypeScript code
- Adding a dependency to a JavaScript/TypeScript workspace
Do NOT load it for React/Next/Angular/Vue framework questions — those are /fe's stack
references — nor for E2E automation, which is /e2e's domain.
Context
This skill exists to make TypeScript reviews boring. The compiler is the first reviewer:
the type system exists to make illegal states unrepresentable, and the lint layer exists
to make forgotten awaits impossible, so humans review design instead of hunting typos.
Runtime trust starts at zero at every I/O edge — nothing crossing one is used before it
is parsed into a known type. Policy — compiler flags, lint severities, formatting,
dependency rules, coverage — lives in checked-in files, not in anyone's head, so "what
does this repo require?" has exactly one answer.
Documentation Lookup (MANDATORY)
Before implementing any feature, check current documentation. The TypeScript compiler line split in 2026, Node's LTS lanes rotate on a fixed calendar, and the package managers have tightened their security defaults at every major; recall is not a source.
Context7 MCP
Use Context7 MCP to retrieve up-to-date documentation for any package or tool:
- Resolve library: Call
mcp__context7__resolve-library-idwith the package name - Query docs: Call
mcp__context7__query-docswith the resolved library ID and your question
When to use: Zod 4 schema APIs, typescript-eslint rule options, Vitest configuration,
MSW 2 handlers, fast-check arbitraries, Node core APIs (node: modules).
Example queries:
- "zod 4 discriminated unions and custom error messages"
- "typescript-eslint flat config projectService setup"
- "vitest 4 coverage thresholds and projects config"
- "msw 2 http handlers and server lifecycle in vitest"
- "fast-check property for a round-trip serializer"
- "node 24 AbortSignal.timeout with fetch"
Web Research
Use WebSearch and WebFetch for anything version- or advisory-shaped:
| Question | Source to check |
|---|---|
| What changed in a TypeScript release | the official TypeScript release notes |
| Which Node line is Active LTS today | the Node.js release schedule |
| Is there an advisory against this package | the GitHub / OSV advisory databases |
| What is the current version of a package | its repository / registry page, at task time |
TypeScript-specific lookup rules
- Verify the compiler line before advising on tooling. TypeScript 6.x and 7.x are different compilers — 7 is the native port. Before recommending anything that consumes the compiler API — typescript-eslint, ts-morph, custom transformers, API-driven codegen — check which line the project actually runs. Advice that assumes the programmatic API exists is wrong on 7.0.
- Verify Node LTS status before pinning engines. The
enginesfloor and the CI matrix follow the release schedule, not memory: which line is Active LTS, which is Maintenance, and when the Current line is promoted all change on a fixed calendar.
Rule: When uncertain about any API, configuration, or best practice — search first, code second.
Versions
| Technology | Version | Notes |
|---|---|---|
| TypeScript (checked in) | 6.0 (2026-03-23) | The bridge release — strict by default, module defaults to esnext; the standard checked-in compiler today |
| TypeScript (native) | 7.0 (GA 2026-07-08) | Native Go compiler. No stable programmatic API until 7.1 — typescript-eslint and ts-morph cannot run on it yet. Adopt as a fast CI type-checker where nothing needs the compiler API; adopt fully once 7.1 ships |
| Node.js | 24 (Active LTS) | 22 is in Maintenance; 26 is Current, LTS Oct 2026. Corepack is removed from Node 25+ |
| pnpm | 10+ (11 current) | 10+ blocks dependency lifecycle scripts by default (onlyBuiltDependencies allowlist); 11 defaults minimumReleaseAge to 1440 minutes (one day). Self-manages via the packageManager field — no Corepack |
| Zod | 4.x | Boundary schemas; Zod Mini is the tree-shakeable build |
| Valibot | 1.x | Sanctioned alternative where bundle size is critical |
| Vitest | 4.1.x | v8 coverage provider is the default; coverage.all was removed in v4 |
| ESLint + typescript-eslint | flat config, recommendedTypeChecked | The standard lint layer (T12); type-aware rules need the compiler API, so lint runs on the checked-in 6.x |
| Prettier | current | The formatter; eslint-config-prettier disables conflicting rules |
| oxlint | current | Optional fast pre-pass before ESLint |
| Biome | 2 | Sanctioned single-binary option for small projects |
| MSW | 2 | Network determinism in tests |
| fast-check | current | Property-based testing |
Volatility note. Versions above are current as of Aug 2026 — re-verify before pinning. The 6.x/7.x split resolves when 7.1 ships its stable API; Node's LTS lanes rotate every October; pnpm's security defaults have tightened at each major.
The Doctrine — the T-standards (BLOCKING at review)
Fourteen standards, grouped by the layer that enforces them. Each is one sentence of
rule, enforced at review, with the complete checked-in artifacts in
references/templates.md. A violation is a review finding, not a style comment.
Compiler as first reviewer
T1. The strictest tsconfig is checked in, not tribal
Rule: The repository carries a tsconfig with strict, noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch,
verbatimModuleSyntax, and isolatedModules on; moduleResolution is nodenext for
libraries and Node services, bundler for bundled apps; skipLibCheck is true as the
pragmatic concession; @ts-nocheck is prohibited and @ts-expect-error requires a
reason string.
TS 6.0 made strict the default, but the flags that catch the real bugs — the indexed
access that might be undefined, the optional property that was assigned undefined
explicitly — are not inside strict. A checked-in config is diffed and reviewed; a
tribal one is renegotiated per PR. The ban-ts-comment rule with
allow-with-description makes the reason string mechanical rather than a courtesy.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true
}
}
Full template with both variants: references/templates.md.
T2. any is prohibited; unknown at boundaries
Rule: any is banned by no-explicit-any at error; data of unknown shape enters as
unknown and is narrowed or parsed before use; catch (err: unknown) always.
any is not a type — it is an instruction to the compiler to stop reviewing, applied
exactly where input is least trusted. unknown accepts the same values but forces the
proof of shape before use, which is the entire point of having a compiler.
// BAD — the compiler is switched off exactly where the data is least trusted
function handle(payload: any) {
return payload.user.id;
}
// GOOD — unknown forces the proof of shape before any property access
function handle(payload: unknown) {
const parsed = PayloadSchema.parse(payload);
return parsed.user.id;
}
T3. Discriminated unions with exhaustive never checks
Rule: Mutually exclusive states are one discriminated union, switched exhaustively
with an assertNever default; the isLoading/data/error boolean trio for async
state is banned by name.
The boolean trio has eight representable states where the domain has three or four, and
every consumer re-derives which are legal. One union makes the illegal states unwritable,
and the assertNever default turns "we added a state" into a compile error at every
switch that must care. This is the same shape rule as the sealed-switch rule in the java
skill (../java/SKILL.md) and the enum rule in the rust skill (../rust/SKILL.md) — an
architect can prescribe one state model and get it enforced in any of the three languages.
// BAD — eight representable states, three legal; the invariant lives in reviewers' heads
interface QueryState<T> {
isLoading: boolean;
data?: T;
error?: Error;
}
// GOOD — exactly the legal states; a new state breaks every switch that must handle it
type QueryState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function label<T>(state: QueryState<T>): string {
switch (state.status) {
case "idle":
return "Idle";
case "loading":
return "Loading";
case "success":
return "Loaded";
case "error":
return state.error.message;
default:
return assertNever(state);
}
}
T4. Branded types for IDs and units
Rule: Every meaning-bearing string or number — IDs, money, durations, unit-carrying
quantities — gets a branded type with a validating constructor function; raw string
does not travel between modules as an identifier.
Two structurally identical strings are interchangeable to the compiler, so a swapped
userId/orderId compiles and corrupts. A brand makes the swap a type error, and the
validating constructor is the only site allowed to mint the brand — so holding the type
is the proof of validation.
type UserId = Brand<string, "UserId">;
function toUserId(raw: string): UserId {
if (!/^[0-9a-f]{24}$/i.test(raw)) throw new Error(`invalid user id: ${raw}`);
return raw as UserId; // the one blessed cast — the constructor mints the brand
}
T5. satisfies for configs, as const for literal sets, bare as is a flag
Rule: Config objects are checked with satisfies so they are validated without
widening; closed literal sets use as const; a bare as cast outside a brand
constructor is a review flag, with the no-unsafe-* rules on to catch what it hides.
as asserts; satisfies verifies. An as-cast config silences the compiler and keeps
the wrong type; satisfies reports the mismatch and preserves the narrow inferred type
for downstream use.
const routes = {
home: "/",
user: "/users/:id",
} as const satisfies Record<string, `/${string}`>;
Runtime boundaries
T6. Parse, don't validate, at every I/O edge
Rule: Everything crossing an I/O edge — HTTP bodies, env vars, file/DB/queue
payloads, postMessage data — is parsed with a schema (Zod 4 by default; Zod Mini where
bundle-sensitive; Valibot 1.x sanctioned for edge/browser-shipped validators), and the
parsed output type is the only type internal code ever sees; process.env may be read
in exactly one parsed config module — a read anywhere else is a violation.
A validation function that returns boolean proves nothing to the compiler; a parse
returns a value whose type is the proof, so the check cannot be forgotten downstream.
Centralizing process.env means the process fails at startup with a complete list of
what is missing, instead of at 3 a.m. on the first code path that needed the variable.
// BAD — trust asserted, never established; the cast is a lie waiting for production
const config = process.env as { PORT: string; DATABASE_URL: string };
// GOOD — parsed once at the edge; internal code imports the typed result
const EnvSchema = z.object({
PORT: z.coerce.number().int().min(1).max(65535),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env);
The full fail-fast env module lives in references/templates.md.
T7. Wire types are generated, never hand-written
Rule: Types for any wire format with a source of truth are generated from it — OpenAPI via openapi-typescript or orval, the database via whatever schema-first codegen the project uses — with generated files committed and a CI step proving regeneration is clean; a hand-written duplicate of a wire type is a design defect.
Two descriptions of one wire format drift, and the drift is invisible until runtime. Generation makes the server contract the single source; the regeneration-clean CI step makes "someone edited the generated file" and "the spec moved" both loud.
Modules & packages
T8. ESM-only in new code
Rule: New packages are "type": "module" with an exports map whose types
condition comes first, engines pinned (">=24"), the packageManager field pinned
exactly, and sideEffects: false only when it is actually true.
Dual CJS/ESM publishing doubles the surface for resolution bugs and the dual-package
hazard; new code has no reason to carry it. The exports map is the public API — what
is not exported does not exist — and types-first ordering is what makes resolvers find
the declarations. A false sideEffects: false lets bundlers delete live code.
{
"type": "module",
"engines": { "node": ">=24" },
"packageManager": "pnpm@11.0.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
Async & errors
T9. No floating promises; cancellation is forwarded
Rule: @typescript-eslint/no-floating-promises and no-misused-promises are errors —
this is the single highest-value bug-class killer in the ecosystem; every long-running
operation accepts and forwards an AbortSignal; fetch uses AbortSignal.timeout(ms);
Promise.all over user-sized input gets explicit concurrency bounding.
An unawaited promise is a silent fork: its rejection is unhandled, its completion is
unordered, and the caller reports success before the work happened. The lint rule makes
the fork visible; void with a rationale is the explicit form of intentional
detachment. Unbounded fan-out over user-sized input is a self-inflicted denial of
service — bound it with a concurrency limiter.
// BAD — the rejection vanishes; the caller returns before the write happened
saveAudit(event);
return result;
// GOOD — awaited, timed out, and cancellable from above
await saveAudit(event, { signal });
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
return result;
T10. The error policy is decided per project and written down
Rule: The default policy: Error subclasses with cause chains preserved
end-to-end; never throw a non-Error; EXPECTED domain failures cross module boundaries
as discriminated-union results the caller must handle; exceptions are reserved for bugs
and broken invariants.
A thrown string has no stack and no cause; a swallowed cause chain turns a root
cause into archaeology. The union-result boundary makes expected failures part of the
signature — the compiler forces the caller to handle "not found" — while genuinely
exceptional states still travel the exception channel where nothing sensible can be
done locally. The Result helper lives in references/templates.md.
T11. Top-level await only in entrypoints
Rule: Top-level await is allowed in entrypoints (main.ts, a CLI's bin file) and
nowhere else; module graphs are otherwise side-effect-free on import.
A library module that awaits at top level makes every importer's load order a runtime
dependency and turns import cycles into deadlocks. Modules define; entrypoints run.
Side-effect-free imports are also what make sideEffects: false (T8) true and tests
importable in isolation.
Toolchain
T12. The lint standard is decided: ESLint flat config + typescript-eslint + Prettier
Rule: The standard is ESLint flat config with recommendedTypeChecked plus Prettier
— not a preference poll; oxlint is sanctioned as an optional fast pre-pass, Biome 2 is
sanctioned for small projects wanting one binary, and no repo ever has two formatters.
The reason is T9 and T2: the highest-value rules — no-floating-promises,
no-misused-promises, the no-unsafe-* family — are type-aware, and typescript-eslint's
implementations are the most mature. Type-aware linting consumes the compiler API, which
is why lint runs on the checked-in 6.x compiler until 7.1 ships (see ## Versions). Two
formatters in one repo means every save is a diff war; pick one and delete the other.
T13. Tests: Vitest, MSW, fast-check — behaviour sentences, deterministic network
Rule: Vitest 4.x (the projects config for monorepos) with behaviour-sentence test
names; network determinism via MSW 2 handlers, never fetch monkey-patching; fast-check
property tests for parsers, serializers, and reducers, each property naming its
invariant; coverage via the v8 provider with thresholds checked in (80% statements /
75% branches).
A test named test1 verifies nothing to a reader; expired_token_is_rejected is a
requirement that happens to be executable. MSW intercepts at the network layer, so the
code under test runs its real fetch path. A property like "decode(encode(x)) === x for
all x" finds the inputs nobody thought to enumerate. Playwright and E2E automation
belong to /e2e — this skill stops at the unit/integration line.
Security
T14. Zero trust in inputs, dependencies, and dynamic code
Rule: User-controlled keys never drive Object.assign or deep-merge — user-keyed
dictionaries are Map or null-prototype objects; eval, new Function, and dynamic
vm on user input are prohibited; regex over user input is ReDoS-safe (no nested
quantifiers, bounded lengths); the lockfile is committed; pnpm 10+'s lifecycle-script
blocking stays ON with an onlyBuiltDependencies allowlist naming only packages vetted
at their exact locked version; the minimum-release-age cooldown stays on (pnpm 11
default: 1440 minutes); pnpm audit or osv-scanner runs in CI; prefer packages
publishing npm provenance; secrets arrive via env and surface only through the T6
config module.
Prototype pollution is what happens when a user-supplied key named __proto__ meets a
recursive merge; install scripts are what turned dependency compromise into remote code
execution on developer machines; the release-age cooldown is what buys the ecosystem a
day to catch a hijacked publish before it reaches your lockfile. None of these defenses
survive being toggled off "temporarily" — widening any of them is a SECOPS trigger.
Deep-dive references (load on demand)
references/templates.md— the complete checked-in artifacts:tsconfig.base.jsonand its library/app variants, the ESLint flat config, thepackage.jsonskeleton,vitest.config.ts, the Zod env module, the utility snippets (assertNever,Brand,Result), and the CI fragment. Load when setting up or reviewing repo policy.- Framework TypeScript — React/Next component types, hooks, server actions — lives with
/fe:../../frontend/react/frontend-developer/SKILL.mdroutes to../../frontend/react/frontend-developer/references/react-expertise.md. This skill stops where the framework starts. - Embedded SQL — query semantics, migrations, dialect rules — is the sql language skill:
../sql/SKILL.md. Cross both ways when a TS service embeds SQL. - Sibling language skills for the cross-language shape rule named in T3:
../rust/SKILL.md(the enum rule),../java/SKILL.md(the sealed-switch rule). - Playwright and E2E automation belong to
/e2e— one pointer, no duplication here.
Workflow note
This skill owns no gates. The workflow-engine contract and the role agents' own gate
checks apply unchanged; this skill only supplies the language knowledge inside them.
TypeScript-specific gate triggers to know (they mirror workflow.yaml — that file decides):
- New dependencies and new package/service boundaries are ARCH triggers
(
new_dependency,new_service,cross_boundary) — and in this ecosystem a dependency brings its transitive graph and its install scripts with it, so the trigger is doing real work. - Security-sensitive surfaces — auth, secrets, parsing external input, anything that
widens the
onlyBuiltDependenciesallowlist or relaxes the release-age cooldown — are SECOPS triggers.
Whether those gates fire is the workflow-engine's decision, not this skill's. What this
skill supplies is the evidence the gates consume: the boundary schemas, the test list,
and the checked-in policy files are what /rev and /verify check against.
Checklist
Before Implementing
- State modelled as discriminated unions — the union written before the code that switches on it (T3)
- Boundary schemas written for every new I/O edge (T6)
- Test list written — behaviour sentences, before any implementation (T13)
- Stack versions confirmed against
## Versions— and re-verified if that table is stale - The compiler line (6.x vs 7.x) confirmed before reaching for compiler-API tooling
Before Commit
-
tsc --noEmitclean under the checked-in tsconfig — not a looser local one (T1) - Lint clean, including the promise rules at error (T9, T12)
- Tests green with coverage thresholds intact (T13)
- No
anyintroduced —unknown+ narrowing at boundaries (T2) - No
process.envread outside the config module (T6) - Change verified as landed — behaviour observed, not assumed (the
verify-landedprocess skill)
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Floating promise | Rejection unhandled; caller reports success before the work happened | await it, or void with a rationale; no-floating-promises at error (T9) |
isLoading/data/error boolean trio |
Eight representable states, three legal; every consumer re-derives the rules | One discriminated union with an exhaustive switch (T3) |
as any escape hatch |
Disables the compiler exactly where the data is least trusted | unknown + narrowing or a schema parse (T2, T6) |
| Hand-written wire type beside a generated one | The two drift; the drift is invisible until runtime | Delete the duplicate; generate from the source of truth (T7) |
Scattered process.env reads |
Config failures surface at 3 a.m. on the first path that needed the variable | One parsed config module, fail-fast at startup (T6) |
| Two formatters in one repo | Every save is a diff war; reviews fill with churn | One formatter; delete the other (T12) |
| CJS/ESM mixing in new code | Dual-package hazard, resolution bugs, two build outputs to test | ESM-only with an exports map (T8) |
| Throwing strings | No stack, no cause, catch cannot narrow |
Throw Error subclasses only (T10) |
Swallowing the cause chain |
The root cause becomes archaeology across rethrow layers | new AppError("context", { cause: err }) end-to-end (T10) |
Unbounded Promise.all over user-sized input |
Self-inflicted denial of service on fan-out | Bound concurrency explicitly (T9) |
@ts-ignore |
Suppresses errors invisibly and outlives its reason | @ts-expect-error with a reason string — it expires when the error does (T1) |
| Interface where a closed variant set belongs | Optional-property soup readmits the illegal states | A discriminated union; interfaces are for open extension (T3) |
| Parsing inside business logic | The same input is re-checked everywhere, or nowhere | Parse once at the edge; internal code takes the parsed type (T6) |
| Test asserting a mock call instead of an outcome | The test passes when the behaviour is wrong but the wiring matches | Assert observable behaviour; use MSW so the real code path runs (T13) |