expert-typescript-programmer
Installation
SKILL.md
Expert TypeScript Programmer
Overview
Use this skill to write TypeScript that is simple at runtime and precise at compile time. Prefer local repo conventions first, then apply the official TypeScript guidance summarized here.
Workflow
- Read the nearest
tsconfig.json, packagepackage.json, and relevant existing source before choosing types. - Model the runtime contract first: inputs, outputs, failure modes, ownership boundaries, and public API shape.
- Use TypeScript to make invalid states hard to represent, but keep the implementation readable JavaScript.
- Validate unknown external data at the boundary; do not pretend unvalidated data already matches an internal type.
- Run the narrowest meaningful validation command before finishing: package typecheck/test for package changes,
pnpm run typecheck:changedandpnpm run test:changedfor broader changes, andpnpm run lintwhen practical.
Repo Rules
- Use
import type { X }andexport type { X }for type-only symbols. - Include
.tsextensions in relative imports and exports, matching this repo'sallowImportingTsExtensions/rewriteRelativeImportExtensionssetup. - Follow public API boundaries: each package export maps to a top-level
src/*.ts;src/libis implementation-only. - Do not re-export APIs or types from another package. Import from the package that owns the symbol.
- Prefer Web APIs and standards-aligned primitives over Node-specific APIs when either works.
- Use repo style:
letfor locals, module-scopeconst, regular functions by default, arrows for callbacks, object method shorthand, native class fields, and#private. - Use descriptive lowercase generic names in this repo, such as
source,pattern, ormethod, instead of single-letter names unless local code already uses that convention.
Package Structure
- Keep public barrels honest: top-level
src/*.tsexport files should re-export each public symbol directly from the module where it is defined, not through asrc/libpass-through barrel. - Treat
src/libfiles as implementation owners. Split implementation by responsibility, but do not add thin re-export wrappers or indirect ownership insidesrc/lib. - Export only APIs that a package consumer should rely on. Do not expose internal factories, data tables, or helper types just because they are useful across implementation modules.
- When moving functionality between modules, move the owning types with it or re-export them only from the package-level public entry point. Avoid making an implementation module look like a public API aggregator.
- Prefer internal factories or closures for configuration that should not leak into helper signatures. Public-facing helpers should not require callers to thread implementation booleans or mode flags through repeated calls.
- If a helper name implies a stronger semantic contract than the implementation provides, either narrow the name or keep it private until a package actually needs it. For example, terminal display width is different from code point length.
- Share constants from a single owning module when separate modules must agree on protocol values, escape sequences, sentinels, or discriminants. Avoid duplicating magic strings that can drift.
- After public API changes, check the package barrel, README examples, tests, and change files together so documentation and exports match the actual supported surface.
API Documentation
- When adding or tightening public JSDoc, use the
write-api-docsskill; it owns detailed JSDoc style, ESLint expectations, and API-docs workflow. - Treat JSDoc as part of the public type contract. It should document behavior, defaults, units, and edge cases that TypeScript cannot express, not repeat type syntax.
- Use names and docs that do not over-promise. If an API only counts code points, do not describe it as terminal display width; if a helper is not ready as a supported contract, keep it internal.
- Keep package metadata, README examples, JSDoc, tests, and change files aligned with the same public API surface.
Type Design
- Let inference work for local variables and callback parameters when the initializer or context is obvious.
- Add explicit return types on exported functions, public methods, overloaded implementations, recursive functions, and functions where the return type is part of the contract.
- Use
unknownfor values that must be inspected before use. Avoidany; it disables useful type checking and spreads unsafety through every value it touches. - Use primitive types
string,number,boolean,symbol, andobject; do not use boxed types likeString,Number,Boolean,Symbol, orObject. - Prefer
interfacefor new public object shapes and option objects. Prefertypefor unions, tuples, mapped types, conditional types, and aliases over non-object shapes. - Represent states with discriminated unions when behavior depends on a mode, status, kind, or variant. Use exhaustive checks when adding or changing variants.
- Prefer literal unions and const objects over enums unless the surrounding package already established enums for that domain.
- Use
satisfieswhen an object literal must conform to a broader type while preserving narrow property inference. - Use
as constfor literal tables, discriminants, and tuples that should remain narrow and readonly. Do not use it to paper over mutable data flow.
Avoid Type Holes
- Do not fix TypeScript errors by adding
any,as SomeType,!,@ts-ignore, or@ts-expect-error. First improve the type model, add control-flow narrowing, validate unknown input, or change the API shape. - Treat explicit
anyas a last resort for migration or broken third-party types only. Preferunknownat boundaries and narrow it before use. - Do not put
anyin public APIs unless the API truly accepts and safely handles every JavaScript value. In almost all new code,unknown, a generic, or a union is a better contract. - Avoid type assertions as routine casting. A TypeScript assertion is erased at runtime; it does not parse, validate, convert, or make an unsafe value safe.
- Use an assertion only when there is a real proof TypeScript cannot express, such as a checked DOM query, a validated schema result, or an external library invariant. Keep the assertion as close as possible to that proof.
- When adapting bad external types, isolate the unsafe assertion in one small helper with a precise return type. Do not let
anyleak through the rest of the package. - Prefer reusable type guards or assertion functions over repeated casts when the same runtime check appears more than once.
- Avoid double assertions like
value as unknown as Target; if one is unavoidable, wrap it at the boundary and document the invariant. - Before accepting a cast, ask whether
satisfies,as const, an explicit return type, a discriminated union, a generic constraint, or a local variable with a narrower type would express the intent without disabling checking.
Null, Optional, And Indexed Values
- Keep
strictNullChecksdiscipline: handlenullandundefinedbefore using a value. - Prefer explicit
value !== undefined,value !== null, orvalue != nullchecks over broad truthiness when'',0,false, or empty arrays are valid values. - Treat
property?: Tas an absent-property model. If the key is always present but the value can be missing, writeproperty: T | undefined. - When indexing arrays, tuples, maps, records, or URL/search params, account for missing values before using the result.
- Use non-null assertions (
!) only when a nearby invariant proves the value exists and the code cannot express that proof cleanly. Keep the assertion local.
Functions And APIs
- Prefer a union parameter over overloads when the implementation and return type are the same for each accepted input shape.
- Use overloads for genuinely different call signatures. Put the most specific overloads first and the most general overload last.
- Do not make callback parameters optional unless the implementation may actually call the callback without that argument.
- Use
() => voidfor callbacks whose return value is ignored. - Avoid
Function; write the callable shape, for example(request: Request) => Response | Promise<Response>. - Avoid boolean flag parameters when they create multiple behavioral modes. Prefer named option objects or discriminated unions.
- Keep public option objects extensible and documented when changing public APIs.
Generics
- Introduce a type parameter only when it relates at least two positions, such as input-to-output, key-to-object, item-to-collection, or callback-to-value.
- Use as few type parameters as possible. Remove parameters that do not change the resulting type.
- Prefer the type parameter itself over a constrained container when that improves inference.
- Add constraints only for capabilities the implementation actually uses, such as
extends { length: number }orextends keyof source. - Prefer inferred type arguments for callers. Require explicit type arguments only when inference cannot represent the intended contract.
- Do not create generic types that ignore their type parameter.
Boundaries And Assertions
- Parse and narrow untrusted inputs before converting them into internal types: JSON, request bodies, headers, environment values, dynamic route params, filesystem data, and third-party responses.
- Keep type assertions close to the runtime proof. Prefer a small type guard or assertion function when the proof is reusable.
- Avoid double assertions like
value as unknown as Targetunless adapting an external type hole; isolate them and explain the invariant if the reason is not obvious. - Do not silence type errors with
@ts-ignoreor@ts-expect-errorunless a test or upstream compatibility case requires it. Include the concrete reason.
Review Checklist
- Does the type model match real runtime behavior, including errors and absent values?
- Did the change preserve package export boundaries and type ownership?
- Are type-only imports and
.tsextensions correct? - Do package-level exports point directly at the owning modules where symbols are defined?
- Did the change avoid turning
src/libmodules into pass-through barrels or accidental public API aggregators? - Is every exported helper/type something consumers should depend on now, not just an implementation convenience?
- Are public JSDoc comments written from the consumer's point of view, documenting semantics instead of repeating types?
- Do package metadata, README examples, JSDoc, and tests describe the same public API surface?
- Are
any, assertions, non-null assertions, and suppressions absent? If not, is each one isolated, proved by nearby runtime logic, and impossible to express with safer TypeScript? - Are generics necessary, minimal, and inference-friendly?
- Are unions narrowed explicitly enough that each branch is safe?
- If this changes public API, are tests, JSDoc, README examples, and change files handled by the relevant repo skills?
Official Sources
- TypeScript Handbook: Everyday Types
- TypeScript Handbook: Narrowing
- TypeScript Handbook: More on Functions
- TypeScript Handbook: Generics
- TypeScript Handbook: Modules
- TypeScript Declaration Files: Do's and Don'ts
- TypeScript Modules: Choosing Compiler Options
- TSConfig: strict
- TSConfig: verbatimModuleSyntax
- TSConfig: exactOptionalPropertyTypes
- TSConfig: noUncheckedIndexedAccess
- TypeScript 4.9: satisfies Operator
- TypeScript 3.4: const Assertions