programming-typescript
SkillType-first TypeScript best practices for AI coding agents: architecture, module boundaries, and pragmatic library choices. Auto-activates in TypeScript projects.
Instructions
Overview
Write code whose types describe real runtime guarantees, with small APIs and explicit failure behavior. As of 2026-10: TypeScript 7.0 (native compiler, released 2026-07-08) is the current stable release; 6.0 remains the line for tools that need the JavaScript compiler API. Read the repository's compiler version, inherited configuration, package exports, runtime targets, and toolchain constraints before applying newer features. A language update does not authorize a dependency or framework migration.
Mental model
TypeScript checks JavaScript before execution; it does not validate JSON, freeze objects, or install runtime APIs. Keep external uncertainty at the boundary, then use ordinary control flow and domain types internally. Prefer a readable concrete type over generic machinery that only abbreviates one implementation.
Version and configuration
- TypeScript 7 is the native compiler, installed through the regular
typescriptpackage (tsc). It ships no programmatic compiler API (a new API is expected in 7.1). Tools that embed the compiler or language service (typescript-eslint type-aware rules, Vue, Svelte, Astro, MDX, Angular templates) stay on 6.0; run them side by side via@typescript/typescript6(tsc6) as the release notes describe. Do not invent a fallback compiler on failure. - In 6/7,
strictdefaults to true,typesto[], androotDirto the configuration directory; 7 also defaultsmoduletoesnextand enablesnoUncheckedSideEffectImports. Declare intended global types and source root explicitly when needed; inspect emitted paths during migration. - TypeScript 7 rejects options deprecated in 6, including
baseUrl,moduleResolution: node10/classic, AMD/UMD/System module output, ES5 targets, andesModuleInterop: false;import ... assertmust becomewith. Fix the underlying configuration; suppressing deprecations is not a durable migration. - When code runs directly under Node's built-in type stripping (no build step), enable
erasableSyntaxOnlyandverbatimModuleSyntax: stripping cannot handleenum,namespacewith values, or parameter properties, and it does not type-check. - Keep
strictenabled. ConsidernoUncheckedIndexedAccessfor potentially missing indexed values andexactOptionalPropertyTypeswhen absence differs from explicitundefined; neither follows automatically fromstrict. Introduce them within the authorized configuration scope. - Match
moduleandmoduleResolutionto execution: Node-aware modes for Node resolution,bundlerfor a bundler.pathschanges type resolution, not emitted imports. Prefer package exports/imports or the existing resolver over new aliases. - Pin
target/libto supported environments. New declarations do not supply polyfills. Useimport typefor erased dependencies, preserving any intentionally required side-effect import.
Types that carry their weight
- Accept
unknownfor untrusted values; validate shape and domain constraints before constructing an internal value. Use existing schema tooling for complex boundaries, not a new dependency for a three-field check. - Prefer discriminated unions to combinations of optional fields and flags. Use exhaustive narrowing with
neverso new variants require a decision. - Use
satisfiesto check a known expression's compatibility while retaining useful inference. It is not runtime validation or a replacement for every annotation. Assertions and non-null!require a concrete invariant. - Annotate public contracts and ambiguous returns; let locals infer. Add a generic only when it expresses a relationship between inputs and outputs. Keep conditional/mapped types bounded and understandable.
readonly,Readonly<T>, andas constprovide compile-time restrictions, not runtime deep immutability. A mutable alias can still change an object. Copy or freeze only where ownership requires it.- Distinguish missing, empty, and invalid values. Use
??only for an intentional nullish default; avoid||when zero, false, or an empty string is valid.
Async work and failures
- Use
awaitfor readable sequential dependencies; promise composition remains legitimate. Preserve an existing async API's rejection semantics when simplifying it. - Start independent bounded work together.
Promise.allrejects on failure but does not cancel siblings;allSettledis useful only when callers actually handle each outcome. - Propagate supported cancellation signals and deadlines through I/O. Avoid detached promises; explicitly own completion and rejection handling.
- Preserve error context with
causewhere supported. Narrow caught values before reading properties. Follow the module's established thrown-error or result-union contract. - Recover only when an alternative is valid for the operation. Never translate a failed request or malformed payload into a successful empty collection. Retry only identified transient failures, with bounds and appropriate idempotency.
Example
Validate a boundary without asserting a desired type:
type User = Readonly<{ id: string; enabled: boolean }>;
function parseUser(value: unknown): User {
if (
typeof value !== "object" || value === null ||
!("id" in value) || typeof value.id !== "string" ||
value.id.length === 0 ||
!("enabled" in value) || typeof value.enabled !== "boolean"
) {
throw new TypeError("Invalid user payload");
}
return { id: value.id, enabled: value.enabled };
}The boolean check preserves valid false; it does not replace malformed input with a default.
Checklist
- Confirm compiler, framework tooling, module resolution, and runtime compatibility.
- Validate external data; justify assertions and intentional defaults.
- Keep state variants exhaustive and public types simpler than their implementations.
- Check cancellation, rejection ownership, and partial-failure semantics.
- Use existing type-check/lint/test scripts and installed tools when execution is authorized; test invalid inputs and meaningful async failures. Report skipped checks.