
Typescript Advanced Patterns
- 91 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
typescript-advanced-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- typescript-advanced-patterns
- AI & Agent Building
- AI-coding skill
Typescript Advanced Patterns by the numbers
- 91 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,765 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill typescript-advanced-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 91 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with typescript-advanced-patterns.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when typescript-advanced-patterns is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to typescript-advanced-patterns: typescript-advanced-patterns; AI & Agent Building; AI-coding skill.
Files
TypeScript Advanced Patterns Best Practices
Type-level programming, library-author idioms, and feature-implementation patterns that go beyond surface uses of TypeScript 5.x. Contains 40 rules across 5 categories, prioritised by impact on consumer codebases.
When to Apply
Reference these guidelines when:
- Designing a public library or DSL surface (fluent builders, event emitters, route parsers, query builders, schema-derived clients)
- Writing type-level algorithms (recursive conditionals, accumulator pattern, key remapping, variadic tuples, type-level string/number ops, type-level tests)
- Using TS 5.x features in non-trivial ways (Stage 3 decorators,
usingcomposition,const Tfor overload disambiguation,NoInferfor anchor-vs-constrained parameters, variance annotations, the bivariance hole) - Encoding workflow state, transitions, and capabilities at the type level so illegal states and missing checks are compile errors
- Integrating with the declaration & module system (module augmentation, declaration merging, ambient asset modules, library type publishing via
exports/typesVersions)
Boundary with neighbouring skills
| Skill | Don't reach for this skill if you need… |
|---|---|
typescript (curated) | Compiler performance / tsconfig tuning |
typescript-refactor | General refactoring patterns and modern-TS surface basics |
ts-google | Google-style code style decisions |
clean-code-ts-react | Clean-code principles (naming, function shape, abstraction) |
effect-ts / opencode-ts | Effect library-specific patterns |
If a rule in this skill overlaps with one in typescript-refactor or .curated/typescript, the rule's Scope delta section names what this skill adds beyond the simpler version.
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Library Author / DSL Patterns | CRITICAL | dsl- | 8 |
| 2 | Type-level Programming | HIGH | tlp- | 10 |
| 3 | Modern Features at Depth | HIGH | mod- | 8 |
| 4 | Feature Implementation Patterns | MEDIUM-HIGH | impl- | 8 |
| 5 | Declaration & Module System | MEDIUM | decl- | 6 |
Quick Reference
1. Library Author / DSL Patterns (CRITICAL)
- `dsl-fluent-builder-phantom-state` — Enforce builder call order with phantom state types
- `dsl-typed-event-emitter` — Build typed event emitters with mapped event maps
- `dsl-type-safe-object-paths` — Type object path access with dot-notation inference
- `dsl-route-param-inference` — Infer route parameters from path patterns
- `dsl-schema-first-inference` — Derive static types from runtime schemas
- `dsl-type-safe-query-builder` — Encode query shape in the builder's return type
- `dsl-narrow-api-surface` — Export only the API surface, not internal helpers
- `dsl-overloads-vs-conditional-returns` — Choose overloads over conditional return types
2. Type-level Programming (HIGH)
- `tlp-recursive-conditional-types` — Use recursive conditional types for structural transformations
- `tlp-tail-recursion-accumulator` — Use tail-recursion accumulator pattern to bypass the 50-step limit
- `tlp-infer-extends-constraints` — Constrain
inferwithextendsfor validated extraction - `tlp-key-remapping-as` — Remap keys with
asclauses in mapped types - `tlp-variadic-tuple-types` — Use variadic tuples for position-aware type algorithms
- `tlp-type-level-string-algorithms` — Build type-level string algorithms with recursive template literals
- `tlp-distributive-conditional-control` — Control distribution with the
[T] extends [U]tuple trick - `tlp-type-level-tests` — Test types with
Equal,Expect, and@ts-expect-error - `tlp-template-literal-pattern-matching` — Match structured strings with
inferin template literals - `tlp-hkt-emulation` — Emulate higher-kinded types with interface dictionaries
3. Modern Features at Depth (HIGH)
- `mod-stage-3-decorators` — Use Stage 3 decorators with decorator context for metaprogramming
- `mod-using-disposal-ordering` — Compose
usingresources with explicit disposal ordering - `mod-const-type-params-overloads` — Use
const Tto preserve literals through overloaded APIs - `mod-noinfer-overload-disambiguation` — Use
NoInfer<T>to disambiguate overloaded function signatures - `mod-variance-debugging` — Debug variance errors with
in/outannotations - `mod-method-vs-property-bivariance` — Prefer property syntax over method syntax to avoid bivariance holes
- `mod-phantom-capability-tracking` — Track capabilities at the type level with phantom brands
- `mod-satisfies-branded-config` — Combine
satisfieswith branded types for validated configuration
4. Feature Implementation Patterns (MEDIUM-HIGH)
- `impl-tagged-result-type` — Model operation outcomes as
Ok<T> | Err<E>tagged unions - `impl-state-discriminated-union` — Model workflow state as a discriminated union of state records
- `impl-finite-state-machine` — Encode FSM transitions in function signatures
- `impl-schema-derived-api-client` — Derive client argument and return types from endpoint schemas
- `impl-type-safe-form-builder` — Drive form-field inference from a single schema definition
- `impl-phantom-feature-flags` — Gate feature-dependent code with phantom capability types
- `impl-assert-never-exhaustive` — Use
assertNeverto force exhaustive handling of union variants - `impl-env-config-loader` — Validate environment configuration at boundary with schema inference
5. Declaration & Module System (MEDIUM)
- `decl-module-augmentation` — Augment third-party module types without patching source
- `decl-declaration-merging` — Merge interface, namespace, and class declarations to extend APIs
- `decl-ambient-asset-modules` — Declare ambient modules for non-TypeScript asset imports
- `decl-global-augmentation-discipline` — Scope global type augmentation to avoid conflicts
- `decl-exports-and-types-versions` — Ship library types with
exportsandtypesVersionsmaps - `decl-authoring-d-ts-for-js` — Author
.d.tsfiles for plain JavaScript libraries
How to Use
Read individual reference files for detailed explanations, code examples, and "when NOT to apply" guidance:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Rules cross-link via [[other-rule-slug]]; follow them when a related pattern is referenced.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
TypeScript
Version 0.1.0 TypeScript Advanced Patterns May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Advanced TypeScript patterns for library/DSL authors and app developers building on top of the type system. Contains 40 rules across 5 categories, prioritised by impact from critical (library/DSL API design) to incremental (declaration & module system). Each rule includes detailed explanations, production-realistic incorrect vs. correct examples, when-NOT-to-apply guidance, and scope deltas relative to overlapping rules in typescript-refactor and .curated/typescript. Covers type-level programming (recursive conditionals, tail-recursion accumulator, infer-extends, key remapping, variadic tuples, type-level strings, type-level tests, HKT emulation), library/DSL patterns (fluent builders with phantom state, typed event emitters, route param extraction, schema-first inference, type-safe query builders), modern TS 5.x features at depth (Stage 3 decorators, using disposal ordering, const-T in overloads, NoInfer disambiguation, variance debugging, method-vs-property bivariance, phantom capabilities), feature implementation patterns (tagged Result, FSM transitions, schema-derived API clients, form builders, assertNever), and declaration/module system topics (module augmentation, declaration merging, ambient asset modules, library publishing via exports/typesVersions).
---
Table of Contents
1. Library Author / DSL Patterns — CRITICAL
- 1.1 Build Typed Event Emitters with Mapped Event Maps — CRITICAL (prevents 100% of event-name typos and payload-shape drift at compile time)
- 1.2 Choose Overloads Over Conditional Return Types — CRITICAL (produces better error messages and 2-5× faster type-checking at call sites; preserves narrowing)
- 1.3 Derive Static Types from Runtime Schemas — CRITICAL (eliminates 100% of type/runtime drift between validators and TypeScript types)
- 1.4 Encode Query Shape in the Builder's Return Type — CRITICAL (prevents 100% of column-name and result-shape mismatches at the call site)
- 1.5 Enforce Builder Call Order with Phantom State Types — CRITICAL (prevents 100% of out-of-order builder calls at compile time)
- 1.6 Export Only the API Surface, Not Internal Helpers — CRITICAL (prevents 100% of downstream coupling to internal types; enables internal refactors without major-version bumps)
- 1.7 Infer Route Parameters from Path Patterns — CRITICAL (prevents 100% of param-name drift between route declarations and handlers)
- 1.8 Type Object Path Access with Dot-Notation Inference — CRITICAL (prevents 100% of broken dot-paths at compile time; enables full autocomplete on nested objects)
2. Type-level Programming — HIGH
- 2.1 Build Type-Level String Algorithms with Recursive Template Literals — HIGH (enables Split/Join/Replace/CamelCase at the type level; eliminates manual string-shape declarations)
- 2.2 Constrain `infer` with `extends` for Validated Extraction — HIGH (prevents 100% of unsafe
ascasts after type-level parsing; produces narrowed primitives instead ofstring) - 2.3 [Control Distribution with the
[T] extends [U]Tuple Trick](references/tlp-distributive-conditional-control.md) — HIGH (prevents 100% of accidental union distribution in helpers; preserves whole-union semantics where needed) - 2.4 Emulate Higher-Kinded Types with Interface Dictionaries — HIGH (enables generic-over-container abstractions (Functor, Monad, Traversable) without needing native HKTs)
- 2.5 Match Structured Strings with `infer` in Template Literals — HIGH (enables URL, CSS, format-string parsing at the type level; eliminates runtime regex for shape extraction)
- 2.6 Remap Keys with `as` Clauses in Mapped Types — HIGH (enables rename, filter, and prefix operations in a single mapped type; replaces multi-pass type pipelines)
- 2.7 Test Types with `Equal`, `Expect`, and `@ts-expect-error` — HIGH (catches 100% of regressions in type-level code at CI time, not at the next call site)
- 2.8 Use Recursive Conditional Types for Structural Transformations — HIGH (enables deep transformations (DeepReadonly, DeepPartial, NonNullableDeep) that would otherwise require code generation)
- 2.9 Use Tail-Recursion Accumulator Pattern to Bypass the 50-Step Limit — HIGH (20× recursion-depth ceiling (50 to ~1000 steps); prevents "Type instantiation is excessively deep" on long tuples and strings)
- 2.10 Use Variadic Tuples for Position-Aware Type Algorithms — HIGH (enables typing of curry, compose, concat, and reverse without combinatorial overload explosion)
3. Modern Features at Depth — HIGH
- 3.1 Combine `satisfies` with Branded Types for Validated Configuration — HIGH (catches 100% of structural drift on config objects without widening to the declared type)
- 3.2 Compose `using` Resources with Explicit Disposal Ordering — HIGH (prevents resource leaks in 100% of composed scopes; guarantees LIFO disposal even on exception paths)
- 3.3 Debug Variance Errors with `in` / `out` Annotations — HIGH (prevents 100% of unintended variance inference; moves errors from consumer call sites to declaration sites)
- 3.4 Prefer Property Syntax Over Method Syntax to Avoid Bivariance Holes — HIGH (prevents 100% of unsound function-parameter assignability on interface members)
- 3.5 Track Capabilities at the Type Level with Phantom Brands — HIGH (enables compile-time "you must do X before Y" enforcement; prevents 100% of unauthorised-use bugs at the type layer)
- 3.6 Use `const T` to Preserve Literals Through Overloaded APIs — HIGH (eliminates 100% of widening losses at overload-heavy call sites; removes the need for
as constat every call) - 3.7 Use `NoInfer<T>` to Disambiguate Overloaded Function Signatures — HIGH (prevents 100% of "argument leaked into inferred default" bugs in multi-parameter generics)
- 3.8 Use Stage 3 Decorators with Decorator Context for Metaprogramming — HIGH (prevents 100% of legacy-decorator type holes (
anyparameters); removes dependency onexperimentalDecoratorsflag)
4. Feature Implementation Patterns — MEDIUM-HIGH
- 4.1 Derive Client Argument and Return Types from Endpoint Schemas — MEDIUM-HIGH (eliminates 100% of input/output drift between client and server; prevents serialisation mismatches)
- 4.2 Drive Form-Field Inference from a Single Schema Definition — MEDIUM-HIGH (prevents 100% of field-name drift between forms, validators, and submission payloads)
- 4.3 Encode FSM Transitions in Function Signatures — MEDIUM-HIGH (prevents 100% of illegal state transitions at the call site; eliminates "guard everywhere" runtime checks)
- 4.4 Gate Feature-Dependent Code with Phantom Capability Types — MEDIUM-HIGH (prevents 100% of "forgot the flag check" bugs at gated code sites; lets the type system enforce the gate)
- 4.5 Model Operation Outcomes as `Ok<T> | Err<E>` Tagged Unions — MEDIUM-HIGH (forces 100% of error paths to be handled at the call site; eliminates
throw-based control flow) - 4.6 Model Workflow State as a Discriminated Union of State Records — MEDIUM-HIGH (prevents 100% of illegal-state-combination bugs (loading + error simultaneously, success + no data))
- 4.7 Use `assertNever` to Force Exhaustive Handling of Union Variants — MEDIUM-HIGH (prevents 100% of "added variant, forgot a handler" regressions across the codebase)
- 4.8 Validate Environment Configuration at Boundary with Schema Inference — MEDIUM-HIGH (prevents 100% of misconfigured-env runtime crashes; pushes errors to startup rather than first-request)
5. Declaration & Module System — MEDIUM
- 5.1 Augment Third-Party Module Types Without Patching Source — MEDIUM (enables typed access to runtime-added properties without forking type definitions)
- 5.2 Author `.d.ts` Files for Plain JavaScript Libraries — MEDIUM (prevents 100% of
any-typed access to JS-only libraries; eliminates per-call-site casts) - 5.3 Declare Ambient Modules for Non-TypeScript Asset Imports — MEDIUM (enables typed
importof SVGs, CSS modules, images, and binary assets without per-file casts) - 5.4 Merge Interface, Namespace, and Class Declarations to Extend APIs — MEDIUM (enables extensible plugin systems, registry patterns, and library-style API surfaces)
- 5.5 Scope Global Type Augmentation to Avoid Conflicts — MEDIUM (prevents global type pollution across packages in a monorepo; eliminates 100% of "two packages collide on Window.foo" bugs)
- 5.6 Ship Library Types with `exports` and `typesVersions` Maps — MEDIUM (ensures 100% of consumers across CJS/ESM/bundler/node resolve types correctly; prevents "works on my repo" reports)
---
References
1. https://www.typescriptlang.org/docs/handbook/2/ 2. https://www.typescriptlang.org/docs/handbook/release-notes/ 3. https://www.totaltypescript.com 4. https://github.com/sindresorhus/type-fest 5. https://effectivetypescript.com 6. https://zod.dev 7. https://github.com/tc39/proposal-decorators 8. https://nodejs.org/api/packages.html#exports 9. https://arethetypeswrong.github.io/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title}
{1-3 sentences explaining WHY this pattern matters — what breaks without it, what cascade effect it has, and what the model should generalise from. Aim to teach the reasoning, not dictate the rule. For "advanced" rules, also state plainly when it's overkill.}
Incorrect ({problem label}):
// Production-realistic anti-pattern. Comment explains the cost.Correct ({solution label}):
// Minimal diff from the incorrect example. Comment explains the benefit.When NOT to apply:
- {Realistic exception 1}
- {Realistic exception 2}
Scope delta (if rule overlaps with typescript-refactor or .curated/typescript):
- Existing rule:
[[other-rule-slug]]covers {what they cover}. - This rule extends to {what this rule adds beyond that}.
Reference: {Title}
{
"version": "0.1.0",
"organization": "TypeScript Advanced Patterns",
"technology": "TypeScript",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Advanced TypeScript patterns for library/DSL authors and app developers building on top of the type system. Contains 40 rules across 5 categories, prioritised by impact from critical (library/DSL API design) to incremental (declaration & module system). Each rule includes detailed explanations, production-realistic incorrect vs. correct examples, when-NOT-to-apply guidance, and scope deltas relative to overlapping rules in `typescript-refactor` and `.curated/typescript`. Covers type-level programming (recursive conditionals, tail-recursion accumulator, infer-extends, key remapping, variadic tuples, type-level strings, type-level tests, HKT emulation), library/DSL patterns (fluent builders with phantom state, typed event emitters, route param extraction, schema-first inference, type-safe query builders), modern TS 5.x features at depth (Stage 3 decorators, using disposal ordering, const-T in overloads, NoInfer disambiguation, variance debugging, method-vs-property bivariance, phantom capabilities), feature implementation patterns (tagged Result, FSM transitions, schema-derived API clients, form builders, assertNever), and declaration/module system topics (module augmentation, declaration merging, ambient asset modules, library publishing via exports/typesVersions).",
"references": [
"https://www.typescriptlang.org/docs/handbook/2/",
"https://www.typescriptlang.org/docs/handbook/release-notes/",
"https://www.totaltypescript.com",
"https://github.com/sindresorhus/type-fest",
"https://effectivetypescript.com",
"https://zod.dev",
"https://github.com/tc39/proposal-decorators",
"https://nodejs.org/api/packages.html#exports",
"https://arethetypeswrong.github.io/"
],
"category": "Lang"
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Library Author / DSL Patterns (dsl)
Impact: CRITICAL Description: Public API surface design is the highest-leverage place to spend type-level effort — every consumer pays for mistakes here. Fluent builders, schema-first inference, route parsers, and overload design cascade to autocomplete quality for thousands of call sites.
2. Type-level Programming (tlp)
Impact: HIGH Description: The compositional toolkit for everything in this skill. Recursive conditionals, accumulator-pattern recursion, key remapping, variadic tuples, and type-level testing are the building blocks for DSLs and inference machinery. Without them, the advanced patterns in other categories cannot be built or verified.
3. Modern Features at Depth (mod)
Impact: HIGH Description: TypeScript 5.x added features that unlock new patterns — Stage 3 decorators, using / await using, const type parameters, NoInfer, variance annotations. The pitfalls only surface in real overload-heavy, capability-tracking, or disposal-composition scenarios. Surface usage is covered elsewhere; this category goes to the edge cases.
4. Feature Implementation Patterns (impl)
Impact: MEDIUM-HIGH Description: Applying advanced types when building features — tagged results, state-machine modeling with discriminated unions, type-safe API clients, form builders, typed config loaders. Bridges library-author primitives with everyday application code so app developers can adopt advanced types without writing the primitives themselves.
5. Declaration & Module System (decl)
Impact: MEDIUM Description: Module augmentation, declaration merging, ambient declarations, and modern library type publishing (exports map, typesVersions). Niche but irreplaceable: when you need them, no other technique works, and getting them wrong silently breaks consumers across module systems.
Declare Ambient Modules for Non-TypeScript Asset Imports
Bundlers (Vite, webpack, Rspack) let you import logo from './logo.svg' or import styles from './card.module.css' and resolve the path at build time. TypeScript by default rejects these — it doesn't know what type to assign to the import. Ambient module declarations (declare module '*.svg') tell the compiler the type of values these imports produce, without telling it anything about the file contents themselves. The result is type-safe imports of assets across the entire project from one declaration file.
Incorrect (rely on bundler globals or per-file casts):
// @ts-expect-error TS doesn't know about .svg imports
import logo from './logo.svg'
// or worse:
const logo = require('./logo.svg') as string // breaks `verbatimModuleSyntax`
// or worst:
const logo: any = (await import('./logo.svg' as any)).defaultCorrect (one ambient declaration per asset kind):
// src/types/assets.d.ts — ambient declarations for all asset kinds the project imports
declare module '*.svg' {
// Vite default: SVG imported as URL string. (For React components, use the ?react query.)
const url: string
export default url
}
declare module '*.svg?react' {
// Vite + vite-plugin-svgr: SVG imported as a React component.
import type { FunctionComponent, SVGProps } from 'react'
const Component: FunctionComponent<SVGProps<SVGSVGElement>>
export default Component
}
declare module '*.module.css' {
const classes: Readonly<Record<string, string>>
export default classes
}
declare module '*.png' {
const url: string
export default url
}
declare module '*.wasm' {
const init: (imports?: WebAssembly.Imports) => Promise<WebAssembly.Instance>
export default init
}Now anywhere in the project:
import logo from './assets/logo.svg' // logo: string (URL)
import LogoIcon from './assets/logo.svg?react' // LogoIcon: React component
import styles from './card.module.css' // styles: Readonly<Record<string, string>>
styles.title // string
styles.titlee // string — class names are not type-checked beyond being a string recordStricter typing for CSS modules — if you use a typegen step (typed-css-modules, vite-plugin-css-modules-types), it emits one .d.ts per CSS file with the actual class names, and styles.titlee becomes a type error. The ambient declaration above is the fallback when typegen isn't wired up.
When NOT to apply:
- Single-file
importof an asset — a per-file.d.tsnext to it (./logo.svg.d.ts) is more precise than a global wildcard. - Bundlers that already ship type definitions for asset imports (some Next.js setups, Bun) — check
tsconfig.json'stypesarray and don't duplicate.
Scope delta:
- No existing TypeScript skill in this repo covers ambient asset modules. Most projects hit this exactly once, copy a fragment from a tutorial, and never revisit. Getting the React-component vs URL-string distinction right (and the
?querysyntax for Vite) is the one detail that saves an afternoon of confusion.
Reference: TypeScript Handbook — Modules: Wildcard Module Declarations
Author .d.ts Files for Plain JavaScript Libraries
Most JavaScript libraries ship types — either inline or via @types/*. When a library doesn't, the choices are: rewrite it (rarely the right call), import as any (loses every benefit of TypeScript), or write your own .d.ts. Writing a focused declaration file is fast, scopes to the surface your code actually uses, and lives in your repo so refactors and version bumps are local concerns. The pattern is more useful than it sounds — even libraries with @types/* packages sometimes ship out-of-date or incomplete types.
Incorrect (`any`-typed everywhere; refactor-safety lost):
// Library: `tiny-emitter` — no types shipped
import TinyEmitter from 'tiny-emitter'
const bus: any = new TinyEmitter()
bus.on('user:loggedIn', (payload: any) => {
// payload is any; typos in event names compile; payload shape lost
})
bus.emit('user:loggedin', { id: 1 }) // typo — no errorCorrect (write a focused `.d.ts` matching the library's runtime surface):
// src/types/tiny-emitter.d.ts
declare module 'tiny-emitter' {
export default class TinyEmitter {
on(event: string, callback: (...args: unknown[]) => void, ctx?: unknown): this
once(event: string, callback: (...args: unknown[]) => void, ctx?: unknown): this
emit(event: string, ...args: unknown[]): this
off(event: string, callback?: (...args: unknown[]) => void): this
}
}Now import TinyEmitter from 'tiny-emitter' gives the typed class. Combine with a typed wrapper to get end-to-end safety:
import TinyEmitter from 'tiny-emitter'
interface AppEvents {
'user:loggedIn': { userId: string; sessionId: string }
'user:loggedOut': { userId: string; reason: 'manual' | 'timeout' }
}
class TypedBus<E extends Record<string, unknown>> {
private inner = new TinyEmitter()
on<K extends keyof E & string>(event: K, handler: (payload: E[K]) => void) {
this.inner.on(event, handler as (...args: unknown[]) => void)
}
emit<K extends keyof E & string>(event: K, payload: E[K]) {
this.inner.emit(event, payload)
}
}
const bus = new TypedBus<AppEvents>()
bus.emit('user:loggedin', {} as never) // Error: not a key of AppEventsFive rules that make the .d.ts reliable:
1. Match the library's runtime contract, not what you wish it did. If a method returns undefined on error, type it Foo | undefined. The declaration's job is to describe reality. 2. Type only the surface you use. A 200-method library used for two calls needs two declarations. Adding the rest is yak-shaving. 3. Prefer `unknown` over `any` for genuine "any shape" parameters. Forces narrowing at the call site — a feature, not a bug. 4. Keep `.d.ts` files in `src/types/` (or similar) with the same base name as the package — tiny-emitter.d.ts, not types.d.ts. Future-you finds them. 5. If the upstream library publishes types later, delete yours. The local declaration silently overrides the package's types, and people will eventually be surprised by the drift.
For non-module JavaScript loaded from a <script> tag, augment the global scope instead (see [[decl-global-augmentation-discipline]]).
When NOT to apply:
- When the library is large and central to the codebase — write proper types or contribute back to
@types/*. A local hack at scale becomes a maintenance burden. - When you can wrap the library behind a thin typed adapter — usually clearer than typing the original API.
Scope delta:
- Companion to
[[decl-module-augmentation]]— that rule extends existing types; this rule writes types where none exist. Together they cover the entire spectrum of integrating untyped or partially-typed external code into a TypeScript codebase.
Reference: TypeScript Handbook — Modules: Working with Plain Old JavaScript Files
Merge Interface, Namespace, and Class Declarations to Extend APIs
TypeScript merges declarations with the same name in the same scope according to specific rules — multiple interface declarations combine into one, namespaces merge their exports, a class can be augmented by a same-named namespace to attach static members, and so on. This is the mechanism behind module augmentation, HKT emulation, and most plugin systems. Knowing exactly which combinations merge — and which don't — is the difference between a working extension point and a confusing compile error.
Incorrect (try to extend a class by re-declaring it — silently shadows):
// src/lib.ts
export class Logger {
log(msg: string) { console.log(msg) }
}
// src/extensions.ts
import { Logger } from './lib'
class Logger { // shadows the import in this file only
static configure(opts: object) { /* … */ }
}
Logger.configure({}) // works here
import('./lib').then(({ Logger }) => Logger.configure({})) // fails — original Logger has no `configure`Correct (merge a namespace into a class to add static members; merge interfaces to extend records):
// 1. Class + namespace merge — adds static-side members and nested types
export class Logger {
log(msg: string) { console.log(msg) }
}
export namespace Logger {
export interface Options { level: 'debug' | 'info' | 'warn' | 'error' }
export function configure(opts: Options): void { /* … */ }
}
Logger.configure({ level: 'info' }) // OK
const opts: Logger.Options = { level: 'debug' } // nested type accessible
// 2. Interface + interface merge — adds members to an existing record
interface UserContext { id: string }
interface UserContext { roles: string[] }
const u: UserContext = { id: 'u_1', roles: ['admin'] } // both fields required
// 3. Namespace + namespace merge — adds exports to an existing namespace
namespace Routes {
export const list = '/users'
}
namespace Routes {
export const create = '/users' // adds to the same namespace
}
// 4. Open-extension registry (the HKT pattern, see `[[tlp-hkt-emulation]]`)
interface PluginRegistry {} // open for extension
declare module './registry' {
interface PluginRegistry { auth: AuthPlugin; cache: CachePlugin }
}What merges, what doesn't:
| Left | Right | Result |
|---|---|---|
interface | interface | Single merged interface (members combined) |
namespace | namespace | Single merged namespace (exports combined) |
class | namespace (same name) | Class + static members from namespace |
function | namespace (same name) | Function + properties from namespace |
enum | namespace (same name) | Enum + helper members from namespace |
class | class | Error — duplicate identifier |
interface | type alias | Error — type aliases can't merge |
class | interface | Only at declaration site — class implements interface, no member merge |
Merging applies per scope. Two interfaces in different modules with the same name do not merge unless you augment via declare module.
When NOT to apply:
- Cases where a clear naming distinction would do —
Loggerplus aLoggerOptionstype is often clearer thanLogger.Options. Reserve merging for genuine extension points and registry patterns. - When the team is unfamiliar with merge semantics — debugging "where did this property come from" across a merged surface is hard. Document every intentional merge.
Scope delta:
- Companion to
[[decl-module-augmentation]]. Module augmentation uses declaration merging across module boundaries; this rule covers the same-scope merge semantics and what shapes are mergeable.
Reference: TypeScript Handbook — Declaration Merging
Ship Library Types with exports and typesVersions Maps
A library that just sets "types": "./dist/index.d.ts" works under the legacy node module resolution and almost nowhere else. Modern consumers — Node ESM, Bun, Vite, TypeScript with moduleResolution: "bundler" or "node16" — go through package.json's exports field, with conditional resolution by environment. Getting this wrong means some consumers see your types and others don't; some import the ESM build and others the CJS; and the diagnoses involve tsc --traceResolution output that even authors find painful. The reliable shape is small, but every key matters.
Incorrect (only `main` and `types` — modern resolvers find nothing):
// package.json
{
"name": "acme-sdk",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
// No `exports` field. Under moduleResolution: "node16" / "nodenext" / "bundler",
// consumers report "Cannot find module 'acme-sdk' or its corresponding type declarations."
}Correct (modern `exports` map with conditional resolution; `typesVersions` as legacy fallback):
// package.json
{
"name": "acme-sdk",
"type": "module",
"main": "./dist/index.cjs", // legacy CJS resolvers
"module": "./dist/index.js", // bundlers that read `module`
"types": "./dist/index.d.ts", // legacy TS (moduleResolution: "node")
"exports": {
".": {
"types": "./dist/index.d.ts", // MUST come first within a condition block
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.cjs"
},
"./package.json": "./package.json" // tooling reads this
},
"typesVersions": { // legacy TS subpath types resolution
"*": {
"client": ["./dist/client.d.ts"]
}
}
}// Consumer's tsconfig.json — any of these will now resolve correctly
{
"compilerOptions": {
"moduleResolution": "bundler" // or "node16", "nodenext"
}
}Five rules that catch the common mistakes:
1. `types` must be the first key in each `exports` condition block. Resolvers walk top-down and stop at the first match. Putting "import" before "types" makes TS read the .js file as types and fail. 2. Include both `import` and `require` for any subpath consumers might use under either module system. A package without require is unimportable from CJS even if a CJS build exists on disk. 3. Subpath exports must be explicit. Once you have an exports field, unlisted subpaths are inaccessible — import 'acme-sdk/internal/util' errors. This is the feature, not a bug. 4. Keep `typesVersions` only for legacy support. It overlaps with exports's types condition. Modern resolvers prefer exports; old ones need typesVersions. Maintain both during migration, drop typesVersions when moduleResolution: "node" is no longer a concern. 5. Expose `./package.json`. Tools (Vite, Webpack, monorepo linkers, type-version detectors) read it; without an explicit entry, they error under moduleResolution: "bundler".
Validate the resulting package with arethetypeswrong (@arethetypeswrong/cli) before publishing — it simulates every consumer scenario and reports broken paths.
When NOT to apply:
- Internal monorepo packages where the consumer's
tsconfigis under your control — direct"types"and"main"are usually enough. - Pure types-only packages (
@types/*-style) — they need onlytypesand don't go through theexportsmachinery.
Scope delta:
- No existing TypeScript skill in this repo covers library type publishing. Companion to
[[dsl-narrow-api-surface]]: that rule controls what a library exports, this rule controls how those exports resolve across consumer toolchains.
Reference: Node.js — Package `exports` Field | Are The Types Wrong
Scope Global Type Augmentation to Avoid Conflicts
Global type augmentation — declare global { interface Window { ... } } — is irresistible the first time you use it. It's also the source of every "two packages tried to type Window.analytics differently and now nothing works" bug in a monorepo. The discipline is to (1) never publish global augmentations from a library, (2) confine them to leaf applications, (3) namespace them with a project-specific brand to prevent merge conflicts, and (4) keep them in a clearly-named *.d.ts file that the whole team recognises as "the global escape hatch."
Incorrect (library publishes a global augmentation; consumers collide):
// In an analytics library — distributed via npm
declare global {
interface Window {
analytics: { track(event: string): void; identify(userId: string): void }
}
}
// In a feature-flag library — same npm install
declare global {
interface Window {
analytics: { variant(flag: string): boolean } // same name, different shape
}
}
// In the application that depends on both:
window.analytics.track('clicked') // OK in some files, type error in others depending on import order
window.analytics.variant('newSearch') // sameCorrect (libraries export, apps augment with namespaced shape):
// analytics library — no global augmentation. Just exports.
// src/index.ts
export interface AnalyticsClient {
track(event: string, props?: Record<string, unknown>): void
identify(userId: string): void
}
export function getAnalytics(): AnalyticsClient { /* … */ }// feature-flag library — same discipline
export interface FlagClient {
variant(flag: string): boolean
}
export function getFlags(): FlagClient { /* … */ }// src/types/global.d.ts — in the application only
import type { AnalyticsClient } from 'analytics-sdk'
import type { FlagClient } from 'feature-flags-sdk'
declare global {
interface Window {
__acme: { // project-namespaced — no risk of collision
analytics: AnalyticsClient
flags: FlagClient
}
}
}
export {} // marker to ensure module status// Usage in the application:
window.__acme.analytics.track('clicked')
window.__acme.flags.variant('newSearch')Five rules that make this safe:
1. Libraries never `declare global`. Export types and let consumers wire them up. 2. Apps namespace globals under a project-specific key (__acme, __internal, the team's short name). Avoid analytics, auth, flags — generic names collide with whatever browser extension a user has installed. 3. One file per global concern, in a predictable path (src/types/global.d.ts, src/types/window.d.ts). New team members find it instantly. 4. `export {}` at the file's bottom to make it a module, not a script — script files implicitly augment the global scope and break tree-shaking in some bundlers. 5. Augment `globalThis` for non-browser globals. declare global { var __cache: Map<string, unknown> } works for Node globals.
When NOT to apply:
- Single-file applications or scripts where the surface is small enough that a flat declaration file is no risk.
- Monorepo internal libraries that are guaranteed to be the only consumer of a global (e.g. a shared test harness) — but document the assumption.
Scope delta:
- No existing TypeScript skill in this repo covers global augmentation discipline. It's the partner rule to
[[decl-module-augmentation]]— both use the same mechanism, but global augmentation has much worse blast radius when done wrong.
Reference: TypeScript Handbook — Global Augmentation
Augment Third-Party Module Types Without Patching Source
A common situation: a library exposes a type that's almost right, but you need to add a field, narrow an enum, or extend an interface for your project's use of it. Editing node_modules is a non-starter; copying the type and forking it is duplication. Module augmentation lets you re-open a third-party module's types from your own code, declaration-merge new properties into its existing interfaces, and have those additions show up at every call site — without touching the source.
Incorrect (cast at every site or maintain a parallel type):
// Express's Request doesn't have `userId` — your auth middleware attaches one
import type { Request } from 'express'
app.get('/me', (req: Request, res) => {
const userId = (req as Request & { userId?: string }).userId // cast everywhere
// …
})
app.get('/orders', (req: Request, res) => {
const userId = (req as any).userId // or worse, `any`
// …
})Correct (augment the module once; every consumer sees the new field):
// src/types/express.d.ts
import 'express'
declare module 'express-serve-static-core' {
interface Request {
userId?: string // attached by auth middleware
requestId: string // attached by tracing middleware (always present after middleware)
tenant?: { id: string; plan: 'free' | 'pro' | 'enterprise' }
}
}// Anywhere in the codebase
app.get('/me', (req, res) => {
const userId = req.userId // string | undefined — recognised
const reqId = req.requestId // string — recognised
})Three rules that make augmentation reliable:
1. *Augment the implementation module, not the re-export. Express's `Request` type is declared in `express-serve-static-core`, not `express`. Augmenting the wrong module silently no-ops. 2. The augmentation file must be a module. A bare `declare module` in a script file augments the global scope instead. Add `import 'express'` (a side-effect import) or `export {}` at the top to ensure module status. 3. Augmented properties must be optional or always-present.* A required property that's actually set by middleware mid-pipeline makes early-pipeline code lie about its state — model it as optional or use a separate post-middleware Request type.
A second canonical use: extending the Window interface for project-specific globals:
// src/types/window.d.ts
export {}
declare global {
interface Window {
analytics: {
track(event: string, props?: Record<string, unknown>): void
identify(userId: string): void
}
}
}When NOT to apply:
- When the library exports types via
classdeclarations rather thaninterface— classes don't declaration-merge. Augment the surrounding namespace, or wrap in your own interface. - When the change is invasive (changing existing field types, removing fields) — augmentation only adds. Fork the type or contribute upstream.
- When the library ships an
@types/*package you can extend differently (tsconfig.jsonpathsortypeRoots); augmentation is the right tool only when you want changes to layer on top of the upstream types.
Scope delta:
- No existing TypeScript skill in this repo covers module augmentation. It's a niche but essential library-integration technique — used by every meaningful Express/Next.js/Vite project.
Reference: TypeScript Handbook — Module Augmentation
Enforce Builder Call Order with Phantom State Types
A fluent builder that exposes every method on every instance is just a wrapper around a mutable object — the type system gives no protection against forgetting required steps. By threading a phantom state type through the builder's generic parameters, the available methods change as required fields are filled, and the final .build() only exists when state proves all preconditions were satisfied. This is the core trick behind compile-time-safe DSLs.
Incorrect (runtime check for required fields):
class QueryBuilder {
private table?: string
private columns?: string[]
from(table: string) { this.table = table; return this }
select(columns: string[]) { this.columns = columns; return this }
build(): string {
if (!this.table) throw new Error('from() is required')
if (!this.columns) throw new Error('select() is required')
return `SELECT ${this.columns.join(',')} FROM ${this.table}`
}
}
new QueryBuilder().build() // Compiles. Throws at runtime.
new QueryBuilder().select(['id']).build() // Compiles. Throws at runtime.
new QueryBuilder().from('users').select(['id']).build() // OK.Correct (phantom state types make .build() unavailable until ready):
type BuilderState = { table: boolean; columns: boolean }
type Ready = { table: true; columns: true }
class QueryBuilder<S extends BuilderState = { table: false; columns: false }> {
private constructor(private parts: { table?: string; columns?: string[] }) {}
static create(): QueryBuilder<{ table: false; columns: false }> {
return new QueryBuilder({})
}
from(table: string): QueryBuilder<S & { table: true }> {
return new QueryBuilder({ ...this.parts, table }) as QueryBuilder<S & { table: true }>
}
select(columns: string[]): QueryBuilder<S & { columns: true }> {
return new QueryBuilder({ ...this.parts, columns }) as QueryBuilder<S & { columns: true }>
}
build(this: QueryBuilder<Ready>): string {
return `SELECT ${this.parts.columns!.join(',')} FROM ${this.parts.table!}`
}
}
QueryBuilder.create().build() // Error: 'this' context not assignable.
QueryBuilder.create().select(['id']).build() // Error: missing { table: true }.
QueryBuilder.create().from('users').select(['id']).build() // OK.The this parameter on build() constrains who can call it. The error message points the caller to the missing call.
When NOT to apply:
- Internal-only builders called from a small, well-tested surface — runtime checks are cheaper to maintain.
- Builders with no required fields (only optional configuration).
- When the call order is enforced by a code generator or schema, not by a hand-written builder.
Scope delta:
typescript-refactor'sarch-branded-typescovers nominal typing for IDs. This rule applies the same nominal-tag mechanism to builder state, where the brand is a record of which methods have been called.
Reference: TypeScript Handbook — Generics with `this` Parameters
Export Only the API Surface, Not Internal Helpers
In a library, every exported type becomes part of the contract — consumers can reference it, structurally extend it, and break when it changes. A common mistake is export * from an index file, which dumps every internal utility type into the public surface. The advanced discipline is to maintain one entry point that re-exports a deliberate surface, and to mark internal helpers with naming and tooling so they cannot leak. This is what lets a mature library refactor internals without major-version churn.
Incorrect (barrel re-export leaks everything):
// src/index.ts — public entry point
export * from './client'
export * from './internal/serializer' // implementation detail
export * from './internal/retry-policy' // implementation detail
export * from './types' // dumps every internal type alias// Consumer
import type { InternalRetryState, SerializerConfig } from 'my-sdk'
// User now depends on names that were never meant to be public.Correct (curated re-exports, internal modules are unreachable):
// src/index.ts — the only public surface
export { Client } from './client'
export type { ClientOptions, RequestContext } from './client'
export { ClientError, NetworkError } from './errors'
export type { Result } from './result'
// Internal helpers are imported only within the package — never re-exported.// package.json
{
"name": "my-sdk",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}// Consumer
import type { ClientOptions } from 'my-sdk' // OK
import type { InternalRetryState } from 'my-sdk' // Error: not exported
import { internalRetry } from 'my-sdk/internal' // Error: subpath not in exports mapPair this with [[decl-exports-and-types-versions]] to block deep-import workarounds, and prefer export type for type-only re-exports so a consumer who erases imports doesn't pull runtime modules along.
When NOT to apply:
- Internal-only monorepo packages — every import site is owned by you, so reorganising surface costs less than maintaining a curated index.
- Plugin systems where consumers genuinely need to extend internals — but then document those types as a separate
@scope/internalspackage with explicit unstable warnings.
Scope delta:
ts-google'smodule-export-api-surfacecovers the general "minimise exports" hygiene rule for in-codebase modules. This rule applies that discipline at the package boundary — combined withexports-map subpath blocking (see[[decl-exports-and-types-versions]]) — so internal types are physically unreachable to consumers, not merely conventionally unused.
Reference: Node.js — Package `exports` Field
Choose Overloads Over Conditional Return Types
Both function overloads and conditional return types can give a function different output types based on its input. They look interchangeable. They are not. Overloads produce per-signature error messages and let the compiler narrow eagerly; conditional return types collapse to a single signature whose return is a deferred expression, which the compiler must re-evaluate on every call. For public DSL surfaces, overloads almost always win. Reach for conditional returns only when the output type depends on runtime-erased data the overloads can't enumerate.
Incorrect (conditional return type — opaque errors, slow inference):
type QueryReturn<Opts> =
Opts extends { single: true } ? User : User[]
function query<Opts extends { id: string; single?: boolean }>(opts: Opts): QueryReturn<Opts> {
// Implementation needs to assert because the return is conditional
return (opts.single ? { id: opts.id } : [{ id: opts.id }]) as QueryReturn<Opts>
}
const user = query({ id: 'u_1', single: true })
// Hover shows: QueryReturn<{ id: string; single: true }>
// The user has to mentally evaluate the conditional to understand what they got.
const result = query({ id: 'u_1', single: maybeFlag })
// Error message: "Argument of type ... is not assignable to QueryReturn<...>"
// Practically unactionable.Correct (overloads — concrete signatures, narrowable errors):
function query(opts: { id: string; single: true }): User
function query(opts: { id: string; single?: false }): User[]
function query(opts: { id: string; single?: boolean }): User | User[] {
return opts.single ? { id: opts.id } : [{ id: opts.id }]
}
const user = query({ id: 'u_1', single: true })
// ^? User — hover shows the resolved overload directly.
const list = query({ id: 'u_1' })
// ^? User[]
query({ id: 'u_1', single: 'yes' })
// Error: 'yes' is not assignable to 'true'. Clear and local.The implementation signature (last one) is internal — callers only see the public overloads. Use conditional return types instead only when: 1. The discriminator is a generic type parameter the caller passes explicitly (function pick<K extends keyof T>(obj: T, key: K): T[K]). 2. There are too many combinations to overload (5+ flags). 3. The return depends on a structural property of an inferred type, not a literal value.
When NOT to apply:
- When you really need return inference parameterised by a literal generic — conditional return types are the only option (see
[[dsl-type-safe-object-paths]]for an example). - When the function genuinely has only one return type but takes many input shapes — neither overloads nor conditional returns; use a discriminated input union.
Scope delta:
typescript-refactor'sgeneric-return-type-inferencecovers preserving inference within generics. This rule covers the broader DSL-design question: should the return shape vary at all, and if yes, with what mechanism?
Reference: TypeScript Handbook — Function Overloads
Infer Route Parameters from Path Patterns
Router APIs that take a path string (/users/:id/posts/:postId) and a handler are everywhere — Express, Hono, Next.js. Without inference, the handler receives params: Record<string, string>, and a route rename leaves stale property accesses scattered across the codebase. Inferring the param object from the route literal — using template-literal infer to extract :name segments — means renaming the route causes the handler to flag every site that still references the old name.
Incorrect (params is a string record):
type Handler = (params: Record<string, string>) => Response
function route(path: string, handler: Handler) { /* ... */ }
route('/users/:userId/posts/:postId', (params) => {
const id = params.userId // string | undefined at best, never refactor-safe
const postId = params.postID // Typo. Returns undefined. Crashes downstream.
return new Response(id ?? postId)
})Correct (params shape inferred from the path literal):
type ExtractParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: Record<string, never>
function route<P extends string>(path: P, handler: (params: ExtractParams<P>) => Response) {
/* ... */
}
route('/users/:userId/posts/:postId', (params) => {
const id = params.userId // string
const postId = params.postId // string
const wrong = params.postID // Error: Property 'postID' does not exist
return new Response(`${id}/${postId}`)
})When the route literal changes from :userId to :authorId, every handler that still destructures userId reports a type error. This is how libraries like Hono and TanStack Router achieve compile-time route safety.
When NOT to apply:
- Routes built at runtime from user input or remote config — the path is
string, not a literal type, and inference cannot run. - Wildcard or regex segments (
*,:id(\\d+)) — the simple parser above does not handle them; either skip those routes or extend the parser with additional template-literal cases.
Scope delta:
- Companion rule to
[[dsl-type-safe-object-paths]]— both rely on template-literalinferto parse string structure, but solve different DSL problems.
Reference: TypeScript Handbook — Inference in Conditional Types
Derive Static Types from Runtime Schemas
When the static type and the runtime validator are declared separately, they drift the moment one changes. The fix is to make the schema the single source of truth and derive the static type from it. Zod, Valibot, and ArkType all expose this via inference helpers (z.infer<typeof schema>, v.InferOutput<typeof schema>, typeof schema.infer). The advanced move — what library authors must understand — is that the schema parses in addition to validating: the output type can differ from the input type (transform, pipe, coerce), so the inferred static type belongs at the parse boundary, not at the validate boundary.
Incorrect (parallel declarations drift on first refactor):
import { z } from 'zod'
interface CreateOrder {
customerId: string
items: { sku: string; quantity: number }[]
notes?: string
}
const createOrderSchema = z.object({
customerId: z.string(),
items: z.array(z.object({ sku: z.string(), quantity: z.number() })),
// Forgot to add `notes` to the schema — runtime accepts orders with no notes,
// but TS thinks the field is optional. No error surfaces.
})
function handleCreateOrder(body: unknown): CreateOrder {
return createOrderSchema.parse(body) as CreateOrder
}Correct (schema is the source; parse output is the type):
import { z } from 'zod'
const createOrderSchema = z.object({
customerId: z.string().brand<'CustomerId'>(),
items: z.array(z.object({
sku: z.string(),
quantity: z.coerce.number().int().positive(),
})),
notes: z.string().optional(),
createdAt: z.string().pipe(z.coerce.date()), // input: string, output: Date
})
type CreateOrder = z.output<typeof createOrderSchema>
// ^? { customerId: string & z.BRAND<'CustomerId'>;
// items: { sku: string; quantity: number }[];
// notes?: string; createdAt: Date }
function handleCreateOrder(body: unknown): CreateOrder {
return createOrderSchema.parse(body) // body: unknown, return: CreateOrder
}Use z.output<…> (or v.InferOutput) for parsed values that downstream code touches, and z.input<…> for the unparsed shape the API actually accepts. Treating them as the same type is the most common schema-first mistake.
When NOT to apply:
- Hot inner loops where the parse cost matters — validate once at the boundary, then cast or pass the parsed value through internally.
- When the schema must be generated from external metadata (OpenAPI, Protobuf) — generate both schema and type from that source, don't hand-derive one from the other.
Scope delta:
typescript-refactor'serror-result-typecovers result-type modeling. This rule covers the upstream problem: where the type comes from in the first place. The two compose — derive the success shape from a schema, wrap it inOk<T>.
Reference: Zod Docs — Inferring Types
Type Object Path Access with Dot-Notation Inference
Helpers that take a string path (get(user, 'address.city')) are common in form libraries, validators, and i18n systems. Without type-level support, the path is a string and the return is any — a single typo silently breaks the contract. A path-aware return type uses recursive template literals to enumerate valid paths and walk them to compute the result type, so refactoring a field rename surfaces every stale path at compile time.
Incorrect (string path, any return):
function get(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj)
}
const user = { profile: { firstName: 'Ada', address: { city: 'Lovelace' } } }
get(user, 'profile.firstName') // any
get(user, 'profile.firstname') // any — typo accepted, returns undefined silently
get(user, 'profile.address.zip') // any — non-existent key acceptedCorrect (paths and return type both inferred):
type Path<T> = T extends object
? { [K in keyof T & string]: T[K] extends object ? `${K}` | `${K}.${Path<T[K]>}` : `${K}` }[keyof T & string]
: never
type PathValue<T, P extends string> =
P extends `${infer K}.${infer Rest}`
? K extends keyof T ? PathValue<T[K], Rest> : never
: P extends keyof T ? T[P] : never
function get<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P> {
return (path as string).split('.').reduce<any>((acc, key) => acc?.[key], obj)
}
const user = { profile: { firstName: 'Ada', address: { city: 'Lovelace' } } }
get(user, 'profile.firstName') // string
get(user, 'profile.address.city') // string
get(user, 'profile.firstname') // Error: not a valid path
get(user, 'profile.address.zip') // Error: not a valid pathThe same PathValue machinery powers typed set, pick, and form-field selectors. Autocomplete now offers every legal path as you type.
When NOT to apply:
- Objects with index signatures or unbounded depth (e.g. trees, recursive AST nodes) —
Path<T>will not terminate or will explode at depth. - Performance-sensitive type-checking in large codebases: deeply nested objects with hundreds of keys can slow the type-checker noticeably. Cap depth with a counter parameter or accept
stringat the public boundary and refine internally. - Bracket-notation access (
'items[0].name') — needs a different parser; this rule covers dot paths only.
Scope delta:
- Combines
[[tlp-recursive-conditional-types]](walking the path) with[[tlp-template-literal-pattern-matching]](splittingK.Rest).
Reference: TypeScript 4.1 Release Notes — Template Literal Types
Encode Query Shape in the Builder's Return Type
A query builder that returns any[] or Record<string, unknown>[] is a string-concatenator with a fluent dressing. The advanced pattern — used by Drizzle, Kysely, and ts-pattern-based DSLs — is to thread the currently-selected columns through the builder's generic parameter so the final .execute() resolves to an exact row shape. Every change to .select() updates downstream .where(), .orderBy(), and result type in lockstep.
Incorrect (column names are strings, results are any):
class Query {
constructor(private table: string, private cols: string[] = []) {}
select(cols: string[]) { return new Query(this.table, cols) }
where(predicate: (row: any) => boolean) { /* ignored at compile time */ return this }
async execute(): Promise<any[]> { /* ... */ return [] }
}
const rows = await new Query('users').select(['id', 'eml']).execute()
// ^^^^^ typo, accepted
rows[0].name // any — no error, undefined at runtimeCorrect (selected columns drive the row type):
type Schema = {
users: { id: number; email: string; name: string; createdAt: Date }
orders: { id: number; userId: number; total: number }
}
type Pick<T, K extends keyof T> = { [P in K]: T[P] }
class Query<Table extends keyof Schema, Cols extends keyof Schema[Table] = keyof Schema[Table]> {
constructor(private table: Table, private cols: readonly Cols[] = [] as never) {}
select<C extends keyof Schema[Table]>(cols: readonly C[]): Query<Table, C> {
return new Query(this.table, cols)
}
where(predicate: (row: Pick<Schema[Table], Cols>) => boolean): this {
return this
}
async execute(): Promise<Pick<Schema[Table], Cols>[]> {
return [] // real impl runs SQL
}
}
const rows = await new Query('users').select(['id', 'email']).execute()
// ^? { id: number; email: string }[]
rows[0].name // Error: Property 'name' does not exist
rows[0].email // string
new Query('users').select(['id', 'eml'])
// ^^^^^ Error: 'eml' is not assignable to keyof Schema['users']The where() callback receives the same projected shape, so predicates can only reference columns that are actually present.
When NOT to apply:
- Truly dynamic queries where columns are decided at runtime (admin tools, ad-hoc analytics) — accept
stringat the public boundary and fall back tounknown-typed rows. - Aggregations and joins beyond simple projection — they need additional type machinery (renaming, conflict resolution) that's worth keeping in a separate rule or library.
Scope delta:
- This is a more elaborate cousin of
[[dsl-fluent-builder-phantom-state]]: the builder's generic parameter tracks what data exists, not which methods have been called.
Reference: Drizzle ORM — Type-safe SQL queries
Build Typed Event Emitters with Mapped Event Maps
A string-keyed event emitter offers zero protection: a typo in the event name silently registers a listener that never fires, and the handler's payload: any lets every consumer drift independently. Typing the emitter by an event map — a record where each key is an event name and each value is the payload type — propagates the contract through on, off, and emit. Every call site is checked against one source of truth.
Incorrect (string events, untyped payloads):
class Emitter {
private listeners = new Map<string, Array<(payload: unknown) => void>>()
on(event: string, handler: (payload: unknown) => void) {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), handler])
}
emit(event: string, payload: unknown) {
for (const h of this.listeners.get(event) ?? []) h(payload)
}
}
const bus = new Emitter()
bus.on('user:loggedIn', (p) => console.log((p as { userId: string }).userId))
bus.emit('user:loggedin', { userId: 'u_1' }) // Silent typo: listener never fires.
bus.emit('user:loggedIn', { id: 'u_1' }) // Compiles. Crashes at first listener.Correct (event map drives both `on` and `emit`):
interface AppEvents {
'user:loggedIn': { userId: string; sessionId: string }
'user:loggedOut': { userId: string; reason: 'manual' | 'timeout' }
'cart:itemAdded': { sku: string; quantity: number }
}
class TypedEmitter<E extends Record<string, unknown>> {
private listeners: { [K in keyof E]?: Array<(payload: E[K]) => void> } = {}
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): void {
(this.listeners[event] ??= []).push(handler)
}
emit<K extends keyof E>(event: K, payload: E[K]): void {
for (const h of this.listeners[event] ?? []) h(payload)
}
}
const bus = new TypedEmitter<AppEvents>()
bus.on('user:loggedIn', ({ userId }) => console.log(userId)) // payload inferred as { userId, sessionId }
bus.emit('user:loggedin', { userId: 'u_1', sessionId: 's' }) // Error: 'user:loggedin' is not a known event.
bus.emit('user:loggedIn', { userId: 'u_1' }) // Error: missing 'sessionId'.Autocomplete now lists all valid event names, and each handler's payload is the exact shape declared in the map.
When NOT to apply:
- Dynamic event names known only at runtime (plugin systems, user-defined events) — the map approach can't represent them.
- Cross-process events where TypeScript can't see both ends of the channel; rely on schema validation at the boundary instead.
Scope delta:
- This pattern composes cleanly with
[[dsl-schema-first-inference]]: deriveAppEventsfrom runtime schemas so the emitter rejects malformed payloads at the source.
Reference: TypeScript Handbook — Mapped Types
Use assertNever to Force Exhaustive Handling of Union Variants
A switch over a discriminated union compiles even when cases are missing — it just falls through, returns undefined, or hits the default. The compiler can verify exhaustiveness, but only if the final fallthrough is typed as never. The assertNever helper formalises this: pass it the discriminated value at the unreachable end of the switch, and the type system errors at every call site that adds a new variant without updating the handler. This is the most cost-effective refactor-safety technique in the whole rule set — three lines of helper code save hours of grep-and-fix.
Incorrect (no exhaustive check — silent miss when a variant is added):
type Notification =
| { kind: 'email'; to: string; subject: string }
| { kind: 'sms'; to: string; body: string }
| { kind: 'push'; deviceToken: string; payload: object }
function send(n: Notification) {
switch (n.kind) {
case 'email': return sendEmail(n.to, n.subject)
case 'sms': return sendSms(n.to, n.body)
// forgot 'push' — silent fall-through, function returns undefined.
}
}
// Later, someone adds:
// type Notification = ... | { kind: 'slack'; channel: string; text: string }
// Every switch in the codebase that doesn't handle 'slack' silently passes through.Correct (`assertNever` at the unreachable branch):
function assertNever(value: never): never {
throw new Error(`unhandled variant: ${JSON.stringify(value)}`)
}
function send(n: Notification) {
switch (n.kind) {
case 'email': return sendEmail(n.to, n.subject)
case 'sms': return sendSms(n.to, n.body)
case 'push': return sendPush(n.deviceToken, n.payload)
default: return assertNever(n) // n is `never` here — all variants accounted for
}
}
// Now add { kind: 'slack'; ... } to Notification:
// Error at `send`: Argument of type 'Notification' is not assignable to parameter of type 'never'.
// Type '{ kind: "slack"; channel: string; text: string }' is not assignable to type 'never'.tsc lights up every switch in the codebase that doesn't handle the new variant. The error message names the unhandled variant directly.
Three places assertNever pays off beyond switches:
// 1. If-chains on discriminants
if (s.status === 'idle') { /* … */ }
else if (s.status === 'loading') { /* … */ }
else if (s.status === 'success') { /* … */ }
else { assertNever(s) } // forces 'error' case to be added
// 2. Object-literal dispatch tables
const handlers: Record<Notification['kind'], (n: Notification) => void> = {
email: (n) => { /* … */ },
sms: (n) => { /* … */ },
push: (n) => { /* … */ },
// Missing 'slack' is an immediate error on the Record's keys.
}
// 3. After narrowing on a tag chain
function describe(n: Notification): string {
if (n.kind === 'email') return `email to ${n.to}`
if (n.kind === 'sms') return `sms to ${n.to}`
if (n.kind === 'push') return `push to ${n.deviceToken}`
return assertNever(n)
}When NOT to apply:
- Switches on open unions (
string,number, anything not closed) —assertNeverwould always fail because the type can't be narrowed tonever. Use adefaultcase that handles unknown values instead. - Library boundaries where the union may legitimately grow externally — exhaustive switches force every minor version bump into a major. Use a default with a typed fallback instead.
Scope delta:
typescript-refactor'snarrow-exhaustive-switchintroduces the idea. This rule covers the full kit — the helper definition, the three usage shapes (switch, if-chain, dispatch record), and the refactor-safety guarantee.assertNeveris the cheapest type-level safety net in TypeScript and frequently the missing piece in codebases that have everything else right.
Validate Environment Configuration at Boundary with Schema Inference
process.env.SOME_KEY is typed as string | undefined, every consumer must null-check it, and missing variables cause failures at the first request that touches them — often hours after deploy. The boundary pattern: parse the whole environment through a schema once at startup, expose the parsed result as a typed object, and crash early on missing or malformed values. Every consumer reads typed properties; no consumer ever touches process.env directly.
Incorrect (raw `process.env` access — late failures, no shape protection):
const PORT = process.env.PORT ?? '3000' // string
const DB = process.env.DATABASE_URL // string | undefined
const RETRY = parseInt(process.env.HTTP_RETRY ?? '3', 10) // NaN if non-numeric env value
app.listen(parseInt(PORT, 10)) // crashes if PORT='abc'
db.connect(DB!) // crashes after first query if DB is missingCorrect (schema-parsed env at startup; typed config exported once):
// src/config.ts — loaded once at startup
import { z } from 'zod'
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
HTTP_RETRY: z.coerce.number().int().min(0).max(10).default(3),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
FEATURE_NEW_CHECKOUT: z.coerce.boolean().default(false),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
// Pretty-print and crash early so the failure is on startup logs, not first-request logs.
console.error('Invalid environment configuration:')
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`)
}
process.exit(1)
}
export const config = parsed.data
// ^ { NODE_ENV: 'development' | 'staging' | 'production';
// PORT: number; DATABASE_URL: string; HTTP_RETRY: number;
// LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
// FEATURE_NEW_CHECKOUT: boolean }
// Everywhere else:
import { config } from './config'
app.listen(config.PORT) // number
db.connect(config.DATABASE_URL) // string
if (config.FEATURE_NEW_CHECKOUT) { /* … */ } // booleanThree design rules:
1. One file owns env parsing. Never process.env.X outside this file. Lint with eslint-plugin-no-process-env or grep in CI. 2. Coerce explicitly. z.coerce.number() turns "3000" into 3000; without it, env strings stay strings and arithmetic explodes silently. 3. Defaults belong in the schema. Don't fall back at the consumer (config.PORT ?? 3000) — defaults in the schema document the contract once.
For multi-environment systems, derive separate types per environment if the contract differs:
const baseSchema = z.object({ /* always present */ })
const prodSchema = baseSchema.extend({ SENTRY_DSN: z.string().url() })
const devSchema = baseSchema.extend({ SENTRY_DSN: z.string().url().optional() })
const schema = process.env.NODE_ENV === 'production' ? prodSchema : devSchemaWhen NOT to apply:
- One-off scripts where the env shape is trivial and the boilerplate exceeds the benefit.
- Edge-runtime environments where
process.envaccess has cost — measure first; in most cases the parse-once pattern still wins.
Scope delta:
- Applies
[[dsl-schema-first-inference]]to the env-config domain. The general schema-first rule says "derive types from schemas"; this rule names the boundary (one file, one schema, one parse at startup) where that pays off most.
Reference: Zod — `safeParse`
Encode FSM Transitions in Function Signatures
A discriminated-union state (see [[impl-state-discriminated-union]]) prevents illegal states. The next step — for workflows with strict ordering — is to prevent illegal transitions. The trick is to type each transition function by its valid input states and its output state: pay(o: Pending): Paid, ship(o: Paid): Shipped. The compiler now refuses to call ship on a Pending order without first calling pay. The runtime body still validates (defense in depth), but the type system carries the workflow contract.
Incorrect (every method accepts any state — runtime guards everywhere):
interface Order {
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
/* … */
}
function pay(o: Order): Order {
if (o.status !== 'pending') throw new Error(`cannot pay an order in ${o.status} state`)
return { ...o, status: 'paid' }
}
function ship(o: Order): Order {
if (o.status !== 'paid') throw new Error(`cannot ship an order in ${o.status} state`)
return { ...o, status: 'shipped' }
}
// Callers can compose transitions in any order — the type system has no idea.
const final = ship(deliver(pay(cancelledOrder))) // compiles; throws at runtimeCorrect (transitions are typed by their valid source states):
type Pending = { status: 'pending'; id: string; lineItems: LineItem[] }
type Paid = { status: 'paid'; id: string; lineItems: LineItem[]; paymentId: string }
type Shipped = { status: 'shipped'; id: string; lineItems: LineItem[]; paymentId: string; trackingId: string }
type Delivered = { status: 'delivered'; id: string; lineItems: LineItem[]; paymentId: string; trackingId: string; deliveredAt: Date }
type Cancelled = { status: 'cancelled'; id: string; reason: string }
type Order = Pending | Paid | Shipped | Delivered | Cancelled
function pay(o: Pending, payment: { id: string }): Paid {
return { ...o, status: 'paid', paymentId: payment.id }
}
function ship(o: Paid, tracking: { id: string }): Shipped {
return { ...o, status: 'shipped', trackingId: tracking.id }
}
function markDelivered(o: Shipped): Delivered {
return { ...o, status: 'delivered', deliveredAt: new Date() }
}
function cancel<S extends Pending | Paid>(o: S, reason: string): Cancelled {
return { status: 'cancelled', id: o.id, reason }
}
// Usage:
const pending: Pending = { status: 'pending', id: 'o_1', lineItems: [] }
const paid = pay(pending, { id: 'p_1' })
const shipped = ship(paid, { id: 't_1' })
const delivered = markDelivered(shipped)
ship(pending, { id: 't_1' }) // Error: 'Pending' is not assignable to 'Paid'.
markDelivered(paid) // Error: 'Paid' is not assignable to 'Shipped'.
cancel(shipped, 'late') // Error: 'Shipped' is not assignable to 'Pending | Paid'.The functions also enrich the state — paymentId only exists after pay, trackingId only after ship. Reading delivered.trackingId is safe without a null check; reading paid.trackingId is a type error.
For workflows with branches (paid → shipped or paid → refunded), give each branch its own transition function with the appropriate input type:
function refund(o: Paid, reason: string): Refunded { /* … */ }When NOT to apply:
- Workflows where transitions are determined at runtime by external events (UI buttons, API webhooks) — you still need the state union, but the transition discipline lives in the reducer that handles events.
- Domains with many states and most-to-most transitions — the per-transition function set explodes. Use a state-machine library (XState) that encodes the transition graph in data, and derive types from that.
Scope delta:
- Extends
[[impl-state-discriminated-union]]from preventing illegal states to preventing illegal transitions. The discriminated-union rule answers "which states are valid"; this rule answers "which sequences of states are valid."
Reference: XState — TypeScript Support
Gate Feature-Dependent Code with Phantom Capability Types
Feature flags are usually a boolean checked inline (if (flags.newCheckout) {...}). The boolean is fine, but the consumers of the new feature have no type-level proof the check ran — a refactor that drops the if compiles silently, and dead code paths that "couldn't happen with the flag off" sometimes do. Tagging the flag's truth with a phantom type, and requiring downstream functions to take that phantom-tagged value, makes the gate part of the type contract. This combines [[mod-phantom-capability-tracking]] with the specific feature-flag use case so the gate cannot be forgotten.
Incorrect (boolean checked in some places, forgotten in others):
interface Flags { newCheckout: boolean; betaUI: boolean }
function renderCheckout(flags: Flags, cart: Cart) {
if (flags.newCheckout) {
return <NewCheckout cart={cart} discountEngine={loadDiscountEngine()} />
}
return <LegacyCheckout cart={cart} />
}
function applyDiscount(cart: Cart, code: string) {
// forgot to check flags.newCheckout — discount engine called from legacy path crashes
const engine = loadDiscountEngine()
return engine.apply(cart, code)
}Correct (flag check produces a phantom-typed value that gates downstream functions):
declare const __flagOn: unique symbol
type FlagOn<F extends string> = { readonly [__flagOn]: F }
interface Flags {
newCheckout: boolean
betaUI: boolean
}
function check<F extends keyof Flags>(flags: Flags, flag: F): (FlagOn<F> | null) {
return flags[flag] ? ({} as FlagOn<F>) : null
}
// Functions that depend on the flag take proof in their signature.
function loadDiscountEngine(proof: FlagOn<'newCheckout'>): DiscountEngine {
// Body cannot run without proof. The proof itself carries no runtime data —
// it exists only at the type level. Caller cannot fabricate it without `check`.
return new DiscountEngine()
}
function applyDiscount(cart: Cart, code: string, proof: FlagOn<'newCheckout'>) {
return loadDiscountEngine(proof).apply(cart, code)
}
function renderCheckout(flags: Flags, cart: Cart) {
const proof = check(flags, 'newCheckout')
if (proof) {
return <NewCheckout cart={cart} engine={loadDiscountEngine(proof)} />
}
return <LegacyCheckout cart={cart} />
}
// At any other call site:
applyDiscount(cart, 'PROMO') // Error: missing FlagOn<'newCheckout'>.
// To call it, the developer must call `check(flags, 'newCheckout')` first.The phantom type adds zero runtime cost — the returned object is {} typed as FlagOn<F>. The only way to manufacture one is to call check, which encapsulates the actual boolean test. Forgetting the check becomes a compile error at the gated call site, with a useful message pointing at the missing capability.
When NOT to apply:
- Flags that gate UI rendering only (show/hide a button). The boolean is enough; the cost of phantom typing exceeds the benefit.
- Flags whose state changes mid-render or mid-request (kill switches that flip during a session). The phantom proof becomes stale; rely on runtime checks at each use.
- Internal kill-switch infrastructure where the type discipline doesn't propagate to consumers (binary-flag feature systems).
Scope delta:
- Applies
[[mod-phantom-capability-tracking]]to the feature-flag domain specifically. The general capability rule explains the mechanism; this rule explains which capabilities feature flags benefit from encoding and where the boundary functions live.
Reference: Martin Fowler — Feature Toggles
Derive Client Argument and Return Types from Endpoint Schemas
A handwritten API client is a parallel definition of the server's contract — every endpoint change requires synchronised edits on two sides, and the sync goes wrong constantly. The end-to-end type pattern (used by tRPC, Hono RPC, oRPC, ts-rest) flips the polarity: define each endpoint's input and output as schemas once, derive both the server route handler's parameter types and the client's call signature from the same source. Wrong arguments fail at the client's compile step; wrong responses fail at the server's compile step.
Incorrect (parallel definitions on client and server drift):
// server/routes/users.ts
app.post('/users', (req, res) => {
const { email, name } = req.body // any
/* … */
res.json({ id: 'u_1', email, name })
})
// client/users.ts
async function createUser(input: { email: string; name: string }): Promise<{ id: string; email: string; name: string }> {
const r = await fetch('/users', { method: 'POST', body: JSON.stringify(input) })
return r.json()
}
// Server adds a required `tenantId` field. Client compiles. Production 400s start flowing.Correct (single schema drives both ends):
// shared/routes.ts — the source of truth, imported by both server and client
import { z } from 'zod'
export const routes = {
createUser: {
method: 'POST',
path: '/users',
input: z.object({ email: z.string().email(), name: z.string().min(1), tenantId: z.string() }),
output: z.object({ id: z.string(), email: z.string(), name: z.string(), tenantId: z.string(), createdAt: z.string().pipe(z.coerce.date()) }),
},
} as const
// server/index.ts — handler typed by the schema
import { routes } from '../shared/routes'
app.post(routes.createUser.path, async (req, res) => {
const input = routes.createUser.input.parse(req.body)
// input: { email: string; name: string; tenantId: string }
const created = await db.users.insert(input)
res.json(routes.createUser.output.parse(created))
})
// client/index.ts — client signature derived from the same schemas
import { routes } from '../shared/routes'
type ClientFor<R extends { input: z.ZodTypeAny; output: z.ZodTypeAny }> =
(input: z.input<R['input']>) => Promise<z.output<R['output']>>
const client: { [K in keyof typeof routes]: ClientFor<(typeof routes)[K]> } = {
createUser: async (input) => {
const validated = routes.createUser.input.parse(input)
const r = await fetch(routes.createUser.path, { method: routes.createUser.method, body: JSON.stringify(validated) })
return routes.createUser.output.parse(await r.json())
},
}
// Usage:
await client.createUser({ email: 'a@b.c', name: 'Ada', tenantId: 't_1' }) // OK
await client.createUser({ email: 'a@b.c', name: 'Ada' }) // Error: missing tenantIdThe server schema change immediately fails the client compile because the input type widened. No coordination, no drift.
Three design rules:
1. Schemas live in a `shared/` package importable by both client and server. Don't put them in server/ and re-export — the client should depend on the schemas directly, not on the server. 2. *Parse on the way in and on the way out. Server parses the request body and the response. Client parses the request input and the response. Two-way parsing catches both directions of drift. 3. Use `z.input` for request shapes and `z.output` for response shapes.* Transforms (coercions, defaults) make these different types; treating them as one is the most common end-to-end-types bug.
When NOT to apply:
- Public APIs consumed by clients you don't control — schema-sharing requires both ends to use the same language and tooling. Ship OpenAPI/JSON Schema instead, or generate code from it.
- Stable, simple endpoints that rarely change — the schema overhead doesn't pay for itself.
Scope delta:
- Combines
[[dsl-schema-first-inference]](schema as source of types) with the bilateral discipline that makes end-to-end typing actually catch drift on both sides.
Reference: tRPC — Quickstart
Model Workflow State as a Discriminated Union of State Records
The classic "loading / error / data" state object is almost always wrong: each field is independently nullable, so the type permits illegal combinations (loading and error true at the same time, data present while still loading). Modelling state as a discriminated union of records — one record per legal state, each carrying exactly the data that state has — makes illegal combinations un-typable. Components and reducers narrow on the tag and access only the fields valid for that tag. This is the structural-typing answer to "make impossible states impossible."
Incorrect (independently nullable fields — combinatorial illegal states):
interface UserDetailState {
isLoading: boolean
user: User | null
error: Error | null
}
function render(state: UserDetailState) {
if (state.isLoading) return <Spinner />
if (state.error) return <ErrorBanner error={state.error} />
if (state.user) return <UserCard user={state.user} />
return null
// Compiles, but {isLoading: true, error: someError, user: someUser} is also a valid value.
// Reducer bugs let it happen. Render is full of `if (state.user)` checks because TS
// can't tell from isLoading=false that user is non-null.
}Correct (one record per legal state — illegal combinations un-typable):
type UserDetailState =
| { status: 'idle' }
| { status: 'loading'; userId: string }
| { status: 'error'; userId: string; error: Error }
| { status: 'success'; user: User }
function render(state: UserDetailState) {
switch (state.status) {
case 'idle': return <EmptyState />
case 'loading': return <Spinner label={`Loading user ${state.userId}`} />
case 'error': return <ErrorBanner error={state.error} onRetry={() => /* … */} />
case 'success': return <UserCard user={state.user} />
// ^ user is User, not User | null
}
}The "tag" key is conventionally status, kind, type, or state — pick one for the codebase and stay consistent. The compiler narrows in switch, if chains, and pattern-matching libraries.
Three implementation rules that pay off in practice:
1. Each state carries only what it needs. Don't put user in loading "just in case the previous user is still there" — model that as a separate state (refreshing with both previousUser and userId) if it matters. 2. Transitions are reducer cases. dispatch({ type: 'fetch', userId }) switches on the current state.status and the action; only valid transitions return a new state. Invalid combinations return the state unchanged. 3. Persist by serialising the union directly — the discriminant goes to JSON cleanly, and a Zod/Valibot schema can re-parse it on load ([[dsl-schema-first-inference]]).
When NOT to apply:
- Forms with many independent fields where each field is genuinely optional — modeling every combination is combinatorial. Use a flat shape with field-level validity instead.
- States with very few distinguishing fields —
{ status: 'open' | 'closed'; closedAt?: Date }is fine; promoting it to a full union is over-engineering.
Scope delta:
typescript-refactor'sarch-discriminated-unionscovers the syntactic pattern. This rule covers the modeling discipline — when to choose a union over a flat record, how to handle transitions, and how illegal-state-prevention compounds across a component tree.
Reference: TypeScript Handbook — Discriminated Unions
Model Operation Outcomes as Ok<T> | Err<E> Tagged Unions
Throwing for failure conditions makes errors invisible at the function signature. The caller cannot tell which calls might fail, what errors they produce, or whether they have been handled. A tagged Result<T, E> makes the failure shape part of the signature: every consumer either narrows on the tag or gets a compile error. This is not the same as wrapping every function in try/catch — Result is for expected failures (validation, not-found, business rule violations); throws remain for unexpected failures (out-of-memory, programmer errors). Done right, the distinction makes both kinds easier to handle.
Incorrect (throws hide failure modes from the signature):
function findUser(id: string): User {
const row = db.query('SELECT * FROM users WHERE id = ?', [id])
if (!row) throw new NotFoundError(id)
if (row.deletedAt) throw new GoneError(id)
return row
}
// At the call site:
const user = findUser('u_42') // No hint that this throws. No hint of which errors. No exhaustive handling.Correct (tagged result with exhaustive handling at call site):
type Ok<T> = { readonly tag: 'ok'; readonly value: T }
type Err<E> = { readonly tag: 'err'; readonly error: E }
type Result<T, E> = Ok<T> | Err<E>
const ok = <T>(value: T): Ok<T> => ({ tag: 'ok', value })
const err = <E>(error: E): Err<E> => ({ tag: 'err', error })
type FindUserError =
| { kind: 'notFound'; id: string }
| { kind: 'gone'; id: string; deletedAt: Date }
function findUser(id: string): Result<User, FindUserError> {
const row = db.query('SELECT * FROM users WHERE id = ?', [id])
if (!row) return err({ kind: 'notFound', id })
if (row.deletedAt) return err({ kind: 'gone', id, deletedAt: row.deletedAt })
return ok(row)
}
// At the call site:
const result = findUser('u_42')
if (result.tag === 'err') {
switch (result.error.kind) {
case 'notFound': return new Response('Not found', { status: 404 })
case 'gone': return new Response('Gone', { status: 410 })
// Missing case ⇒ assertNever forces it to be added (see `[[impl-assert-never-exhaustive]]`)
}
}
const user = result.value // narrowed to UserTwo design rules that make Result pay off:
1. The error type is a discriminated union per concrete failure mode — never string or Error. The discriminant (kind) lets the call site pattern-match. 2. Boundary functions translate `Result` to whatever the framework wants (HTTP response, throw, Slack message). Keep the Result discipline inside the business layer; convert at the edge.
Composition — chain Results without nested if ladders:
function mapResult<T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> {
return r.tag === 'ok' ? ok(f(r.value)) : r
}
function chainResult<T, U, E1, E2>(r: Result<T, E1>, f: (t: T) => Result<U, E2>): Result<U, E1 | E2> {
return r.tag === 'ok' ? f(r.value) : r
}When NOT to apply:
- Functions whose only failure mode is programmer error or impossible-in-practice — throwing is shorter and clearer.
- When a downstream framework (Express handler, GraphQL resolver) already wraps everything in try/catch — wrapping again with
Resultdoubles the layering for no gain.
Scope delta:
typescript-refactor'serror-result-typecovers the basic idea (using anok: true/falseshape). This rule uses an alternativetag: 'ok' | 'err'discriminator, adds the discriminated-error pattern, the composition helpers, and the rule about where the boundary translation happens. Either shape is fine — pick one per codebase and stay consistent.
Reference: TypeScript Handbook — Discriminated Unions
Drive Form-Field Inference from a Single Schema Definition
In most React form codebases, the field list lives in the JSX, the validation rules live in the validator config, and the submission shape lives in the API client — three sources of truth that drift independently. The advanced application of schema-first inference ([[dsl-schema-first-inference]]) to forms is to make the schema produce the field names, the validators, and the typed onSubmit handler in one declaration. React Hook Form, Conform, and TanStack Form all support this; the pattern is general.
Incorrect (parallel sources — field renames break silently):
// FormFields.tsx
function CreateUserForm() {
const { register, handleSubmit } = useForm()
return (
<form onSubmit={handleSubmit(submit)}>
<input {...register('email', { required: true, pattern: /.+@.+/ })} />
<input {...register('fullName', { required: true })} /> {/* renamed from "name" */}
<button>Submit</button>
</form>
)
}
async function submit(data: { email: string; name: string }) { // stale name
await api.createUser(data) // payload mismatch — server gets fullName, client thinks it's `name`
}Correct (schema drives field names, types, and validators):
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
const createUserSchema = z.object({
email: z.string().email(),
fullName: z.string().min(1).max(120),
marketingOptIn: z.boolean().default(false),
})
type CreateUserInput = z.input<typeof createUserSchema>
function CreateUserForm({ onSubmit }: { onSubmit: (input: CreateUserInput) => void }) {
const { register, handleSubmit, formState } = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{formState.errors.email && <p>{formState.errors.email.message}</p>}
<input {...register('fullName')} />
{formState.errors.fullName && <p>{formState.errors.fullName.message}</p>}
<label><input type="checkbox" {...register('marketingOptIn')} /> Marketing</label>
<input {...register('nonExistentField')} /> {/* Error: not a key of CreateUserInput */}
<button disabled={!formState.isValid}>Submit</button>
</form>
)
}Renaming fullName to displayName in the schema produces type errors at every register('fullName') call and at every consumer's onSubmit. No silent drift.
Compose this with [[impl-schema-derived-api-client]] and the same schema (or a transform of it) becomes the API payload, closing the loop from input field → validated state → request body → server side.
When NOT to apply:
- Highly dynamic forms where the field list changes at runtime (admin tools, configurable surveys). The static schema cannot represent variable shapes — use a registry pattern with
Record<string, FieldDef>and accept that field-name safety is local rather than end-to-end. - Forms with one or two fields where the schema overhead exceeds the benefit.
useState<string>is fine for a search box.
Scope delta:
- Companion to
[[dsl-schema-first-inference]]. The schema-first rule says "derive types from schemas"; this rule applies that discipline to forms, where the consequence is field-name autocomplete and end-to-end submit safety, not just type narrowing.
Reference: React Hook Form — Zod Resolver
Use const T to Preserve Literals Through Overloaded APIs
TypeScript 5.0's const type parameters tell the compiler to infer the narrowest type for a generic — string literals stay as their literals, arrays stay as tuples — without the caller writing as const at the call site. The standard rule is "use it for literal inference," which sells the feature short. The advanced application is in overloaded APIs and higher-order combinators where widening at one position cascades to wrong overload selection, wrong return types, and wrong autocomplete several layers down. This is the pattern Zod, Drizzle, and Hono use to keep literal types alive across their entire fluent surface.
Incorrect (no `const` — caller must remember `as const` everywhere):
function route<Path extends string>(path: Path): { path: Path } { return { path } }
const r = route('/users')
// ^? { path: string } — widened. Subsequent param-extraction can't see the literal.
const r2 = route('/users' as const)
// ^? { path: '/users' } — works but requires discipline at every call.Correct (`const T` keeps the literal in inference):
function route<const Path extends string>(path: Path): { path: Path } { return { path } }
const r = route('/users')
// ^? { path: '/users' } — literal preserved automatically.
const r2 = route('/users/:id', { method: 'GET' as const })
// ^? { path: '/users/:id' }The depth payoff appears in overloaded fluent APIs where the literal at one call decides which overload fires at the next:
type RouteMethods = 'GET' | 'POST'
function endpoint<const M extends RouteMethods>(method: M): Builder<M>
function endpoint(method: RouteMethods): Builder<RouteMethods>
function endpoint(method: RouteMethods): Builder<RouteMethods> { /* … */ return {} as any }
interface Builder<M extends RouteMethods> {
// Different shapes per method:
body: M extends 'POST' ? (schema: unknown) => Builder<M> : never
handler: (h: M extends 'GET' ? () => Response : (body: unknown) => Response) => void
}
endpoint('POST').body(/* … */).handler(body => new Response()) // 'POST' selected
endpoint('GET').handler(() => new Response()) // 'GET' selected; `body` is `never`Without const, 'POST' widens to RouteMethods, both branches resolve to unknown, and the user sees never everywhere.
Three places const T pays off most: 1. Tuples passed positionally — const T extends readonly unknown[] keeps positions and length alive. 2. Path strings driving downstream inference — see [[dsl-route-param-inference]]. 3. Discriminator values in tagged unions — keeps the discriminant narrowed for downstream conditional types.
When NOT to apply:
- Generic functions whose return doesn't depend on the literal value —
constadds noise. PlainT extends stringis fine. - When the caller deliberately passes a runtime value (
route(req.path)) — the literal-preservation request is silently ignored, but the noise remains.
Scope delta:
typescript-refactor'smodern-const-type-parametersintroducesconst Tas a literal-preservation feature. This rule is about the overload-disambiguation and cascading-inference use cases — the situations where forgettingconstproduces wrong overload selection, not just slightly widened types.
Reference: TypeScript 5.0 Release Notes — `const` Type Parameters
Prefer Property Syntax Over Method Syntax to Avoid Bivariance Holes
TypeScript's strictFunctionTypes makes function-type assignability contravariant in parameters — for function-typed property members. For method members (declared with the method-call syntax name(...): T), it intentionally keeps bivariant parameter checking for backward compatibility with array methods on subtypes. The result: the same logical signature is sound under one declaration style and unsound under the other. Library authors writing interfaces consumed by structural-subtyping code should default to property syntax everywhere parameters matter.
Incorrect (method syntax — bivariance hole accepts an unsound assignment):
class Animal { name = '' }
class Dog extends Animal { breed = '' }
interface Listener<T> {
notify(value: T): void // method syntax — bivariant parameter
}
const dogListener: Listener<Dog> = { notify(d) { console.log(d.breed) } }
const animalListener: Listener<Animal> = dogListener
// ^ Accepted even under strict mode. animalListener.notify(new Animal()) crashes at runtime
// because notify expects a Dog (accesses .breed).
animalListener.notify(new Animal()) // 💥 TypeError: cannot read property 'breed' of undefinedCorrect (property syntax — contravariant parameters under strictFunctionTypes):
interface Listener<T> {
notify: (value: T) => void // property syntax — contravariant parameter
}
const dogListener: Listener<Dog> = { notify: d => console.log(d.breed) }
const animalListener: Listener<Animal> = dogListener
// ^ Error: Type 'Listener<Dog>' is not assignable to type 'Listener<Animal>'.
// Types of property 'notify' are incompatible.The same code, the same intent — different soundness because of where the ( sits.
Quick reference:
| Syntax | Where it appears | Parameter variance |
|---|---|---|
notify(v: T): void | interface, type, class | Bivariant (unsound) |
notify: (v: T) => void | interface, type | Contravariant (sound, under strictFunctionTypes) |
notify(v: T): void in a class | class method | Bivariant — same hole |
notify = (v: T): void => … | class field with arrow | Contravariant — sound |
The bivariance hole is preserved deliberately for Array<T> and DOM types — too much code relies on it (Array<Dog> assignable to Array<Animal> works only because forEach(callbackfn(value: T)) is bivariant in T). When writing a new interface that doesn't need that legacy escape hatch, choose property syntax.
In classes, fields with arrow functions also use contravariant typing — but they have a different cost (per-instance allocation, no super access). Reserve them for the cases where soundness matters more than memory.
When NOT to apply:
- When deliberately modelling collection-like types that should follow array variance — rare in application code, occasionally needed when polyfilling built-in shapes.
- Class methods where you need
superaccess or method-decoration. The bivariance hole is the cost of doing business with class semantics; either accept it or shift the surface to a function returning an object literal.
Scope delta:
- No existing TypeScript skill in this repo covers the method-vs-property bivariance hole. It is the single most common source of unsound assignability bugs in libraries declaring callback-bearing interfaces.
Reference: TypeScript 2.6 Release Notes — `--strictFunctionTypes`
Use NoInfer<T> to Disambiguate Overloaded Function Signatures
NoInfer<T> (TypeScript 5.4) marks a generic parameter position as non-inferring — the type-checker reads the type but does not let arguments at that position influence the generic's inference. The standard advice is to use it to make defaults work correctly. The advanced application is in overload-heavy APIs where one argument should anchor the generic and the others should follow. Without NoInfer, the compiler picks up clues from every parameter and resolves the generic to the union of all of them — usually wider than intended, sometimes selecting the wrong overload entirely.
Incorrect (every parameter contributes to inference — generic widens):
function pick<T extends string>(options: T[], fallback: T): T {
return options[0] ?? fallback
}
const choice = pick(['red', 'green'], 'blue')
// ^? 'red' | 'green' | 'blue' — fallback widened the result.
// Caller wanted: "choose from options, with fallback for the empty case."
// They got: "result might be the fallback value too."Correct (anchor inference on `options`; do not infer from `fallback`):
function pick<T extends string>(options: T[], fallback: NoInfer<T>): T {
return options[0] ?? fallback
}
const choice = pick(['red', 'green'], 'blue')
// ~~~~~~ Error: 'blue' is not assignable to 'red' | 'green'.
const valid = pick(['red', 'green'], 'red')
// ^? 'red' | 'green' — exactly the options union.The pattern generalises to any signature with one driving parameter and several constrained parameters:
// Reducer: state shape anchored to the initial value.
function createStore<S>(initial: S, reducer: (state: NoInfer<S>, action: unknown) => NoInfer<S>) {
/* … */
}
// Subscription: event shape anchored to the schema, not the handler's parameter inference.
function subscribe<E>(schema: Schema<E>, handler: (event: NoInfer<E>) => void) {
/* … */
}
// Type-safe routing: param shape anchored to the path, not the handler's destructure.
function route<P extends string>(path: P, handler: (params: NoInfer<ExtractParams<P>>) => Response) {
/* … */
}The diagnostic when NoInfer triggers is far more actionable than the alternative — the error points at the specific argument that violated the anchor, instead of producing an inscrutable widened union at the call site.
When NOT to apply:
- Single-parameter generics — there's nothing to disambiguate; the constraint already determines inference.
- When you want the union — sometimes "any of these strings" is the desired return type. Don't reach for
NoInferreflexively. - TypeScript versions before 5.4. Polyfills (
type NoInfer<T> = [T][T extends any ? 0 : never]) exist but produce worse errors than the built-in.
Scope delta:
typescript-refactor'smodern-noinfer-utilityintroducesNoInfer<T>. This rule covers the overload-disambiguation and anchor-vs-constrained-parameter framing — the design pattern for picking which positions infer and which constrain, not just the syntax.
Reference: TypeScript 5.4 Release Notes — `NoInfer` Utility Type
Track Capabilities at the Type Level with Phantom Brands
Branded types are usually presented as a way to distinguish IDs. The advanced application is capability tracking — using brands to record what has been done to a value, so downstream functions can require evidence of those operations. Validated<T>, Authenticated<User>, Sanitised<string>, Permitted<Request, 'admin'> — each is a phantom marker that costs zero runtime and turns "did we remember to call validate?" into a compile error. This is how libraries like Effect track effects in the type system and how authorisation frameworks enforce role checks structurally.
Incorrect (capability lives in runtime state — easy to skip):
function login(req: Request): { user: User; isAdmin: boolean } {
const user = authenticate(req)
return { user, isAdmin: user.role === 'admin' }
}
function deleteAccount(target: string, isAdmin: boolean) {
if (!isAdmin) throw new Error('Forbidden')
/* … destructive operation … */
}
// Anywhere downstream, the boolean can be forgotten or hardcoded.
deleteAccount('u_42', true) // compiles, "true" passed without any check having run.Correct (capability is a phantom brand carried by the type):
declare const __brand: unique symbol
type Branded<T, B> = T & { readonly [__brand]: B }
type Authenticated<U> = Branded<U, 'Authenticated'>
type Admin<U> = Branded<U, 'Admin'>
function authenticate(req: Request): Authenticated<User> {
/* verify token, etc. */
return req.user as Authenticated<User> // tag created at the boundary, only here
}
function requireAdmin(u: Authenticated<User>): Admin<User> {
if (u.role !== 'admin') throw new Error('Forbidden')
return u as Admin<User>
}
function deleteAccount(target: string, actor: Admin<User>) {
/* … destructive operation … */
}
// Usage:
const auth = authenticate(req) // Authenticated<User>
const admin = requireAdmin(auth) // Admin<User>
deleteAccount('u_42', admin) // OK
deleteAccount('u_42', auth) // Error: Authenticated<User> is not assignable to Admin<User>
deleteAccount('u_42', req.user as User) // Error: User is not assignable to Admin<User>The brand is a phantom — there is no [__brand] property at runtime, just a type-level tag. The only way to manufacture an Admin<User> is to go through requireAdmin, which encapsulates the check. Skipping the check requires explicit as and shows up immediately in code review.
Compose brands for multiple capabilities at once:
type CsrfChecked = { readonly __csrf: 'checked' }
type RateLimited = { readonly __rate: 'limited' }
function handleRequest(
req: Request & Authenticated<User> & CsrfChecked & RateLimited,
) { /* … */ }The handler now refuses to be called unless every check has been applied to the same value.
When NOT to apply:
- When the capability check is genuinely runtime-only (user input, external state) and there's no boundary function that can stamp the brand — the brand reduces to documentation.
- For deeply branching control flow where the brand must be added and removed in nested ways. Effect-style monadic effect tracking handles that case better; phantom brands are best for linear "first do A, then do B" pipelines.
Scope delta:
typescript-refactor'sarch-branded-typescovers branded IDs. This rule covers branded capabilities — same nominal-typing mechanism, applied to the question "what has been done to this value?" rather than "what kind of value is this?"
Reference: TypeScript Playground — Nominal Typing
Combine satisfies with Branded Types for Validated Configuration
The satisfies operator (TS 4.9) checks that a value conforms to a type without changing the value's inferred type. The standard advice — "use satisfies over annotation for config objects" — captures the basic value. The advanced pattern is to pair satisfies with branded constraint types so the config not only matches a shape, but carries proof of validation (length limits, enum membership, format) through to call sites. The runtime value stays as its narrow literal shape; the type system records that the value passed the structural check.
Incorrect (`as Config` or `: Config` annotation widens away literal shape):
interface RouteConfig {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
path: string
cache: { ttlSeconds: number; staleWhileRevalidate?: number }
}
const routes: Record<string, RouteConfig> = {
listUsers: { method: 'GET', path: '/users', cache: { ttlSeconds: 60 } },
newUser: { method: 'POST', path: '/users', cache: { ttlSeconds: 0 } },
}
routes.listUsers.method // 'GET' | 'POST' | 'PUT' | 'DELETE' — widened. autocomplete is useless.
routes['typo'] // RouteConfig | undefined — typo not caughtCorrect (`satisfies` keeps literals, brands enforce extra invariants):
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
declare const __validatedPath: unique symbol
type ValidatedPath = string & { readonly [__validatedPath]: true }
function path<P extends `/${string}`>(p: P): P & ValidatedPath {
// The template-literal constraint forces a leading slash at compile time.
return p as P & ValidatedPath
}
const routes = {
listUsers: { method: 'GET', path: path('/users'), cache: { ttlSeconds: 60 } },
newUser: { method: 'POST', path: path('/users'), cache: { ttlSeconds: 0 } },
getUser: { method: 'GET', path: path('/users/:id'), cache: { ttlSeconds: 30 } },
} satisfies Record<string, {
method: HttpMethod
path: ValidatedPath
cache: { ttlSeconds: number; staleWhileRevalidate?: number }
}>
routes.listUsers.method // 'GET' — literal preserved
routes.listUsers.path // ValidatedPath — carries proof of leading-slash check
routes.getUser.path // ValidatedPath
routes['typo'] // Error: Property 'typo' does not exist on type {...}.
// Adding `{ method: 'PATCH', path: path('users'), ... }`:
// - 'PATCH' fails the satisfies check (not in HttpMethod)
// - path('users') fails the template-literal constraint (no leading slash)
// Both errors point at the offending field, not at an opaque union.The combination — satisfies for shape, brand for invariant — gives a one-line declaration the same guarantees a 50-line parse-and-validate runtime check would. The brand survives into every site that reads the config.
When NOT to apply:
- For values constructed from runtime input (user form, env var) —
satisfiescannot validate runtime data. Pair with[[dsl-schema-first-inference]]at the boundary instead. - For simple
as constimmutability requirements — no brand, no satisfies, justas const. Reach for this rule when the shape constraint matters, not just the const-ness.
Scope delta:
typescript-refactor'sarch-satisfies-over-annotationandarch-const-assertionintroducesatisfiesandas constindependently. This rule combines them with branded constraint types to encode invariants in the config's resulting type — going beyond shape conformance into invariant conformance.
Reference: TypeScript 4.9 Release Notes — The `satisfies` Operator
Related skills
FAQ
What does typescript-advanced-patterns do?
typescript-advanced-patterns is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use typescript-advanced-patterns?
When you need to helps with ai & agent building tasks during ai-assisted development, or when typescript-advanced-patterns is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
typescript-advanced-patterns; AI & Agent Building; AI-coding skill.