
Typescript Refactor
- 649 installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
typescript-refactor is a Claude Code skill that applies 47 prioritized TypeScript 6.0 and React 19 TSX modernization rules—from type architecture through compiler performance—for developers who need to harden legacy or f
About
typescript-refactor is a version 1.2.0 agent skill from pproenca/dot-skills that encodes 47 prioritized rules across 9 categories for TypeScript 6.0 and React 19 TSX. Each rule includes an explanation, a production-realistic example, and an authoritative reference, ordered from critical type architecture and narrowing to incremental quirks. It covers modern features such as satisfies, using, const type parameters, inferred type predicates, isolatedDeclarations, erasable syntax, and import attributes. Developers reach for typescript-refactor when an AI agent is refactoring components, tightening types, or migrating patterns without guessing at compiler-level best practices. The skill is designed for AI agents and LLMs acting as a TypeScript principal specialist during code modernization sessions.
- 47 rules across 9 categories prioritized from critical type architecture to incremental pitfalls
- TypeScript 6.0 coverage: satisfies, using, const type parameters, inferred predicates, isolatedDeclarations
- React 19 / TSX: ref-as-prop, ComponentProps, discriminated props, synthetic events
- Compiler-performance guidance including interfaces-over-intersections (CRITICAL rule)
- Production-realistic examples and authoritative references per rule
Typescript Refactor by the numbers
- 649 all-time installs (skills.sh)
- Ranked #205 of 1,356 Code Review & Quality skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill typescript-refactorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 649 |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you modernize TypeScript React code with prioritized refactor rules?
Modernize and harden TypeScript and React 19 TSX code using 47 prioritized rules from type architecture through compiler performance.
Who is it for?
Frontend and full-stack developers maintaining TypeScript 6.0 or React 19 codebases who want agent-driven refactors grounded in 47 ordered rules instead of ad hoc style fixes.
Skip if: Greenfield projects with no TypeScript debt, teams that only need ESLint formatting, or backends with no TSX or React surface area.
When should I use this skill?
A developer asks to modernize, harden, or refactor TypeScript or React 19 TSX and wants rule-backed changes across types, narrowing, or compiler performance.
What you get
Prioritized refactor guidance, typed TSX examples, and rule-by-rule references aligned to TypeScript 6.0 and React 19.
- Rule-guided TSX refactors
- Modernized type patterns
- Referenced examples per rule
By the numbers
- 47 prioritized rules across 9 categories
- Targets TypeScript 6.0 and React 19 TSX
- Skill version 1.2.0 (May 2026)
Files
TypeScript Refactor Best Practices
Comprehensive TypeScript and TSX refactoring and modernization guide designed for AI agents and LLMs. Contains 47 rules across 9 categories, prioritized by impact to guide automated refactoring, code review, and code generation. Current to TypeScript 6.0 and React 19.
When to Apply
Reference these guidelines when:
- Refactoring TypeScript or React/TSX code for type safety and maintainability
- Designing type architectures (discriminated unions, branded types, generics)
- Narrowing types to eliminate unsafe
ascasts - Typing React components and hooks (props, refs, events, state) in
.tsxfiles - Adopting modern TypeScript 5.x–6.0 features (
satisfies,using, const type parameters, inferred type predicates, erasable syntax,withimport attributes) - Optimizing compiler performance in large codebases (
isolatedDeclarations, project references) - Implementing type-safe error handling patterns
- Reviewing code for TypeScript quirks and pitfalls
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Type Architecture | CRITICAL | arch- |
| 2 | Type Narrowing & Guards | CRITICAL | narrow- |
| 3 | Modern TypeScript | HIGH | modern- |
| 4 | React & TSX | HIGH | tsx- |
| 5 | Generic Patterns | HIGH | generic- |
| 6 | Compiler Performance | MEDIUM-HIGH | compile- |
| 7 | Error Safety | MEDIUM | error- |
| 8 | Runtime Patterns | MEDIUM | perf- |
| 9 | Quirks & Pitfalls | LOW-MEDIUM | quirk- |
Quick Reference
1. Type Architecture (CRITICAL)
- `arch-discriminated-unions` — Use discriminated unions over string enums for exhaustive pattern matching
- `arch-branded-types` — Use branded types for domain identifiers to prevent value mix-ups
- `arch-satisfies-over-annotation` — Use
satisfiesfor config objects to preserve literal types - `arch-interfaces-over-intersections` — Extend interfaces instead of intersecting types for better error messages
- `arch-const-assertion` — Use
as constfor immutable literal inference - `arch-readonly-by-default` — Default to readonly types for function parameters and return values
- `arch-avoid-partial-abuse` — Avoid
Partial<T>abuse for builder patterns
2. Type Narrowing & Guards (CRITICAL)
- `narrow-custom-type-guards` — Replace
aswith runtime-checked guards; TS 5.5+ infers the predicate - `narrow-assertion-functions` — Use assertion functions for precondition checks
- `narrow-exhaustive-switch` — Enforce exhaustive switch with
never - `narrow-in-operator` — Narrow with the
inoperator for interface unions - `narrow-eliminate-as-casts` — Eliminate
ascasts with proper narrowing chains
3. Modern TypeScript (HIGH)
- `modern-using-keyword` — Use the
usingkeyword for resource cleanup - `modern-const-type-parameters` — Use const type parameters for literal inference
- `modern-template-literal-types` — Use template literal types for string patterns
- `modern-noinfer-utility` — Use
NoInferto control type parameter inference - `modern-verbatim-module-syntax` — Enable
verbatimModuleSyntaxfor explicit import types - `modern-erasable-syntax` — Prefer erasable syntax over enums and namespaces for type-stripping
- `modern-import-attributes` — Use
withimport attributes instead of deprecatedassert
4. React & TSX (HIGH)
- `tsx-avoid-react-fc` — Type props directly instead of
React.FC - `tsx-ref-as-prop` — Pass
refas a prop instead offorwardRef(React 19) - `tsx-extend-native-props` — Extend native element props with
ComponentPropsWithRefinstead of redeclaring them - `tsx-discriminated-props` — Model mutually-exclusive props as discriminated unions
- `tsx-event-handler-types` — Type event handlers with React synthetic event types
- `tsx-hook-typing` — Type
useState/useReffor nullable and mutable state
5. Generic Patterns (HIGH)
- `generic-constrain-dont-overconstrain` — Constrain generics minimally
- `generic-avoid-distributive-surprises` — Control distributive conditional types
- `generic-mapped-type-utilities` — Build custom mapped types for repeated transformations
- `generic-return-type-inference` — Preserve return type inference in generic functions
6. Compiler Performance (MEDIUM-HIGH)
- `compile-explicit-return-types` — Add explicit return types to exported functions
- `compile-avoid-deep-recursion` — Avoid deeply recursive type definitions
- `compile-project-references` — Use project references for monorepo builds
- `compile-base-types-over-unions` — Use base types instead of large union types
- `compile-isolated-declarations` — Enable
isolatedDeclarationsfor parallel declaration emit
7. Error Safety (MEDIUM)
- `error-result-type` — Use Result types instead of thrown exceptions
- `error-exhaustive-error-handling` — Use exhaustive checks for typed error variants
- `error-typed-catch` — Type catch clause variables as
unknown - `error-discriminated-error-unions` — Model domain errors as discriminated unions
8. Runtime Patterns (MEDIUM)
- `perf-union-literals-over-enums` — Use union literals instead of enums — enums are non-erasable
- `perf-avoid-delete-operator` — Avoid the
deleteoperator on objects - `perf-object-freeze-const` — Use
Object.freezewithas constfor true immutability - `perf-object-keys-narrowing` — Avoid
Object.keystype widening - `perf-map-set-over-object` — Use
MapandSetover plain objects for dynamic collections
9. Quirks & Pitfalls (LOW-MEDIUM)
- `quirk-excess-property-checks` — Understand excess property checks on object literals
- `quirk-empty-object-type` — Avoid the
{}type — it means non-nullish - `quirk-structural-typing-escapes` — Guard against structural typing escape hatches
- `quirk-variance-annotations` — Use variance annotations to document generic intent (not for speed)
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
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 6.0 / TSX (React 19)
Version 1.2.0 TypeScript Principal Specialist May 2026
---
Abstract
Comprehensive TypeScript and TSX refactoring and modernization guide designed for AI agents and LLMs. Contains 47 rules across 9 categories, prioritized by impact from critical (type architecture, narrowing) to incremental (quirks and pitfalls). Each rule includes a detailed explanation, a production-realistic example, and an authoritative reference. Current to TypeScript 6.0 and React 19: covers modern TypeScript features (satisfies, using, const type parameters, inferred type predicates, isolatedDeclarations, erasable syntax, with import attributes), React/TSX component and hook typing (ref-as-prop, ComponentProps, discriminated props, synthetic events), compiler performance, and type-safe error handling.
---
Table of Contents
1. Type Architecture — CRITICAL
- 1.1 Avoid Partial Type Abuse for Builder Patterns — HIGH (prevents accessing properties that were never set)
- 1.2 Default to Readonly Types — HIGH (prevents accidental mutation, catches side-effect bugs at compile time)
- 1.3 Extend Interfaces Instead of Intersecting Types — CRITICAL (2-3× faster type-checking, detects property conflicts)
- 1.4 Use as const for Immutable Literal Inference — HIGH (prevents type widening, enables literal-based narrowing)
- 1.5 Use Branded Types for Domain Identifiers — CRITICAL (prevents cross-domain ID mix-ups at compile time)
- 1.6 Use Discriminated Unions Over String Enums — CRITICAL (eliminates entire classes of invalid state bugs)
- 1.7 Use satisfies for Config Objects Instead of Type Annotations — CRITICAL (preserves literal types while validating structure)
2. Type Narrowing & Guards — CRITICAL
- 2.1 Eliminate as Casts with Proper Narrowing Chains — HIGH (removes 80-90% of type assertions through control flow)
- 2.2 Enforce Exhaustive Switch with never — CRITICAL (prevents silent fallthrough when union members expand)
- 2.3 Narrow with the in Operator for Interface Unions — HIGH (eliminates as casts with 1-line property checks)
- 2.4 Use Assertion Functions for Precondition Checks — CRITICAL (narrows types while validating invariants in a single call)
- 2.5 Write Custom Type Guards Instead of Type Assertions — CRITICAL (eliminates unsafe as casts with runtime-verified narrowing)
3. Modern TypeScript — HIGH
- 3.1 Enable verbatimModuleSyntax for Explicit Import Types — MEDIUM (prevents runtime import of type-only modules)
- 3.2 Prefer Erasable Syntax Over Enums and Namespaces — HIGH (keeps code runnable under Node.js type-stripping)
- 3.3 Use Const Type Parameters for Literal Inference — HIGH (eliminates as const at call sites)
- 3.4 Use NoInfer to Control Type Parameter Inference — MEDIUM-HIGH (prevents incorrect inference from secondary parameters)
- 3.5 Use Template Literal Types for String Patterns — MEDIUM-HIGH (eliminates invalid string formats at compile time)
- 3.6 Use the using Keyword for Resource Cleanup — HIGH (prevents resource leaks by guaranteeing cleanup)
- 3.7 Use with Import Attributes Instead of assert — MEDIUM (replaces the assert syntax deprecated for removal in TS 7.0)
4. React & TSX — HIGH
- 4.1 Extend Native Element Props Instead of Redeclaring Them — HIGH (inherits every DOM attribute and prevents prop drift)
- 4.2 Model Mutually-Exclusive Props as Discriminated Unions — HIGH (makes impossible prop combinations a compile error)
- 4.3 Pass ref as a Prop Instead of forwardRef — HIGH (removes forwardRef boilerplate; ref becomes a normal prop)
- 4.4 Type Event Handlers with React Synthetic Event Types — MEDIUM-HIGH (types event.target/currentTarget and replaces any)
- 4.5 Type Props Directly Instead of React.FC — HIGH (makes children opt-in and enables generic components)
- 4.6 Type useState and useRef for Nullable and Mutable State — MEDIUM (prevents null-unsafe state assignments and ref access)
5. Generic Patterns — HIGH
- 5.1 Build Custom Mapped Types for Repeated Transformations — MEDIUM (eliminates manual type duplication across related interfaces)
- 5.2 Constrain Generics Minimally — HIGH (enables wider reuse without sacrificing type safety)
- 5.3 Control Distributive Conditional Types — MEDIUM-HIGH (prevents unexpected union expansion in type transformations)
- 5.4 Preserve Return Type Inference in Generic Functions — MEDIUM (enables precise downstream typing without manual annotation)
6. Compiler Performance — MEDIUM-HIGH
- 6.1 Add Explicit Return Types to Exported Functions — MEDIUM-HIGH (measurably faster incremental builds in large codebases)
- 6.2 Avoid Deeply Recursive Type Definitions — MEDIUM-HIGH (prevents exponential type-checking time and IDE freezes)
- 6.3 Enable isolatedDeclarations for Parallel Declaration Emit — MEDIUM-HIGH (enables per-file .d.ts emit without whole-program checking)
- 6.4 Use Base Types Instead of Large Union Types — MEDIUM (avoids O(n²) comparison overhead for large unions)
- 6.5 Use Project References for Monorepo Builds — MEDIUM (3-10× faster incremental builds in large codebases)
7. Error Safety — MEDIUM
- 7.1 Model Domain Errors as Discriminated Unions — MEDIUM (enables precise error handling with full type safety)
- 7.2 Type Catch Clause Variables as unknown — MEDIUM (prevents unsafe property access on caught errors)
- 7.3 Use Exhaustive Checks for Typed Error Variants — MEDIUM (prevents silent fallthrough when error variants expand)
- 7.4 Use Result Types Instead of Thrown Exceptions — MEDIUM (eliminates unhandled exception paths across call sites)
8. Runtime Patterns — MEDIUM
- 8.1 Avoid Object.keys Type Widening — MEDIUM (prevents string[] return type from losing key precision)
- 8.2 Avoid the delete Operator on Objects — MEDIUM (prevents V8 deoptimization from hidden class transitions)
- 8.3 Use Map and Set Over Plain Objects for Dynamic Collections — MEDIUM (O(1) operations with better memory and iteration performance)
- 8.4 Use Object.freeze with as const for True Immutability — MEDIUM (prevents both compile-time and runtime mutation)
- 8.5 Use Union Literals Instead of Enums — MEDIUM (removes non-erasable runtime emit; enables type-stripping)
9. Quirks & Pitfalls — LOW-MEDIUM
- 9.1 Avoid the {} Type — It Means Non-Nullish — LOW-MEDIUM (prevents accepting any non-null value when you mean "empty object")
- 9.2 Guard Against Structural Typing Escape Hatches — LOW-MEDIUM (prevents extra properties from leaking through assignments)
- 9.3 Understand Excess Property Checks on Object Literals — LOW-MEDIUM (prevents silent extra-property bugs in direct assignments)
- 9.4 Use Variance Annotations to Document Generic Intent — LOW-MEDIUM (documents and enforces intended variance on type parameters)
---
References
1. https://www.typescriptlang.org/docs/handbook/ 2. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html 3. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html 4. https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html 5. https://github.com/microsoft/TypeScript/wiki/Performance 6. https://react.dev/learn/typescript 7. https://react.dev/blog/2024/12/05/react-19 8. https://react-typescript-cheatsheet.netlify.app/ 9. https://www.totaltypescript.com 10. https://effectivetypescript.com
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on type safety, maintainability, or performance implications.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{// Comments explaining the cost}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{// Comments explaining the benefit}{Optional sections as needed:}
When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.2.0",
"organization": "TypeScript Principal Specialist",
"technology": "TypeScript 6.0 / TSX (React 19)",
"date": "May 2026",
"abstract": "Comprehensive TypeScript and TSX refactoring and modernization guide designed for AI agents and LLMs. Contains 47 rules across 9 categories, prioritized by impact from critical (type architecture, narrowing) to incremental (quirks and pitfalls). Each rule includes a detailed explanation, a production-realistic example, and an authoritative reference. Current to TypeScript 6.0 and React 19: covers modern TypeScript features (satisfies, using, const type parameters, inferred type predicates, isolatedDeclarations, erasable syntax, with import attributes), React/TSX component and hook typing (ref-as-prop, ComponentProps, discriminated props, synthetic events), compiler performance, and type-safe error handling.",
"references": [
"https://www.typescriptlang.org/docs/handbook/",
"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html",
"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-5.html",
"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-8.html",
"https://github.com/microsoft/TypeScript/wiki/Performance",
"https://react.dev/learn/typescript",
"https://react.dev/blog/2024/12/05/react-19",
"https://react-typescript-cheatsheet.netlify.app/",
"https://www.totaltypescript.com",
"https://effectivetypescript.com"
]
}
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. Type Architecture (arch)
Impact: CRITICAL Description: Structural type design decisions cascade through entire codebases. Wrong type architecture forces workarounds, unsafe casts, and maintenance nightmares everywhere downstream.
2. Type Narrowing & Guards (narrow)
Impact: CRITICAL Description: Proper narrowing eliminates entire classes of runtime errors and removes the need for unsafe type assertions. Missing narrowing is the #1 cause of as cast abuse.
3. Modern TypeScript (modern)
Impact: HIGH Description: TypeScript 5.x–6.0 features replace verbose legacy patterns with concise, type-safe alternatives, and keep code erasable so it runs under Node.js type-stripping. Adopting them reduces boilerplate and removes constructs deprecated on the path to TypeScript 7.0.
4. React & TSX (tsx)
Impact: HIGH Description: Typing React components and hooks in .tsx files. Corrects the wrong defaults models still reach for — React.FC, forwardRef, hand-redeclared DOM props, any event handlers — and applies React 19 + @types/react 19 idioms.
5. Generic Patterns (generic)
Impact: HIGH Description: Generics are TypeScript's most powerful and most abused feature. Right constraints enable inference; wrong ones force explicit annotation and hurt readability.
6. Compiler Performance (compile)
Impact: MEDIUM-HIGH Description: Type-level decisions directly affect build times and IDE responsiveness in large codebases. A single recursive type can add seconds to every compilation.
7. Error Safety (error)
Impact: MEDIUM Description: Type-safe error handling makes impossible states unrepresentable and forces callers to handle every failure mode at compile time.
8. Runtime Patterns (perf)
Impact: MEDIUM Description: TypeScript idioms that affect emitted JavaScript performance. Choosing the right construct at the type level determines the cost at runtime.
9. Quirks & Pitfalls (quirk)
Impact: LOW-MEDIUM Description: TypeScript behaviors that violate developer expectations. Understanding these quirks prevents subtle bugs that survive code review and pass type checking.
Avoid Partial Type Abuse for Builder Patterns
Partial<T> makes every property optional, which means TypeScript cannot distinguish between "not yet set" and "intentionally omitted." Use discriminated states or builder types to model progressive construction.
Incorrect (Partial allows incomplete objects to escape):
function createUser(input: Partial<User>): User {
return {
id: input.id ?? generateId(),
name: input.name ?? "Unknown",
email: input.email ?? "", // Empty string — valid but wrong
role: input.role ?? "viewer",
}
}
const user = createUser({}) // All defaults — silent bugCorrect (require mandatory fields, optional for the rest):
interface CreateUserInput {
name: string
email: string
role?: "viewer" | "editor" | "admin"
}
function createUser(input: CreateUserInput): User {
return {
id: generateId(),
name: input.name,
email: input.email,
role: input.role ?? "viewer",
}
}
const user = createUser({}) // Compile error: missing name and emailWhen NOT to use this pattern:
- Patch/update operations where any subset of fields is valid (use
Partial<Pick<T, K>>)
Use Branded Types for Domain Identifiers
Plain string or number types allow accidental swaps between semantically different identifiers. Branded types add a phantom tag that makes each ID type unique without runtime overhead.
Incorrect (all IDs are plain strings):
function assignOrder(userId: string, orderId: string) {
// ...
}
const userId = "usr_abc123"
const orderId = "ord_xyz789"
assignOrder(orderId, userId) // Swapped — compiles fine, fails silentlyCorrect (branded types catch swaps):
type UserId = string & { readonly __brand: "UserId" }
type OrderId = string & { readonly __brand: "OrderId" }
function assignOrder(userId: UserId, orderId: OrderId) {
// ...
}
const userId = "usr_abc123" as UserId
const orderId = "ord_xyz789" as OrderId
assignOrder(orderId, userId) // Compile error: OrderId not assignable to UserIdAlternative (factory function avoids `as` casts):
function createUserId(raw: string): UserId {
if (!raw.startsWith("usr_")) throw new Error("Invalid user ID")
return raw as UserId
}Reference: TypeScript Handbook - Type Branding
Use as const for Immutable Literal Inference
Without as const, TypeScript widens object and array literals to mutable base types. as const preserves exact literal types and marks everything readonly, enabling discriminated unions, tuple inference, and compile-time validation.
Incorrect (literals widened to base types):
const httpMethods = ["GET", "POST", "PUT", "DELETE"]
// Type: string[] — literals lost
const endpoint = { path: "/users", method: "GET" }
// Type: { path: string; method: string } — not assignable to literal unionsCorrect (literals preserved with as const):
const httpMethods = ["GET", "POST", "PUT", "DELETE"] as const
// Type: readonly ["GET", "POST", "PUT", "DELETE"]
const endpoint = { path: "/users", method: "GET" } as const
// Type: { readonly path: "/users"; readonly method: "GET" }
type HttpMethod = (typeof httpMethods)[number] // "GET" | "POST" | "PUT" | "DELETE"Reference: TypeScript Handbook - const assertions
Use Discriminated Unions Over String Enums
String enums provide no structural guarantee about which properties exist for a given variant. Discriminated unions tie shape to kind, making invalid states unrepresentable and enabling exhaustive pattern matching.
Incorrect (string enum with loose object):
enum OrderStatus {
Pending = "pending",
Shipped = "shipped",
Delivered = "delivered",
}
interface Order {
id: string
status: OrderStatus
trackingNumber?: string // Optional for all statuses — easy to forget
deliveredAt?: Date
}
function processOrder(order: Order) {
if (order.status === OrderStatus.Shipped) {
console.log(order.trackingNumber) // Could be undefined
}
}Correct (discriminated union, shape tied to status):
interface PendingOrder {
id: string
status: "pending"
}
interface ShippedOrder {
id: string
status: "shipped"
trackingNumber: string // Required — compiler enforces it
}
interface DeliveredOrder {
id: string
status: "delivered"
trackingNumber: string
deliveredAt: Date
}
type Order = PendingOrder | ShippedOrder | DeliveredOrder
function processOrder(order: Order) {
if (order.status === "shipped") {
console.log(order.trackingNumber) // Guaranteed to exist
}
}When NOT to use this pattern:
- Simple enumerations with no variant-specific data (e.g., log levels) — use a union literal type
- When you need runtime iteration over all values — use an erasable
as constobject, not anenum(see `perf-union-literals-over-enums`)
Reference: TypeScript Handbook - Narrowing
Extend Interfaces Instead of Intersecting Types
Intersection types recursively merge properties and can silently produce never on conflicts. Interfaces create a single flat object type, detect property conflicts at declaration, and are cached by the compiler for faster checking.
Incorrect (intersection hides conflicts, slower):
type BaseEntity = {
id: string
createdAt: Date
}
type Timestamped = {
createdAt: string // Conflict: Date vs string
updatedAt: Date
}
type User = BaseEntity & Timestamped // createdAt becomes never — no errorCorrect (interface detects conflicts, faster):
interface BaseEntity {
id: string
createdAt: Date
}
interface Timestamped extends BaseEntity {
updatedAt: Date
}
interface User extends Timestamped {
email: string
}
// If createdAt types conflict, compiler reports error immediatelyPerformance benefit: The compiler caches interface types but must recursively flatten intersections on every use. In large codebases with hundreds of type references, this compounds into measurable build time increases.
When NOT to use this pattern:
- When you need union types (interfaces cannot represent unions)
- When composing types dynamically with mapped/conditional types
Reference: TypeScript Performance Wiki - Preferring Interfaces Over Intersections
Default to Readonly Types
Mutable types permit accidental mutation that causes subtle bugs — especially when objects are shared across functions. Default to Readonly<T>, readonly properties, and ReadonlyArray<T>, then selectively opt into mutation only where needed.
Incorrect (mutable by default, mutation leaks):
function sortUsersByAge(users: User[]) {
return users.sort((a, b) => a.age - b.age) // Mutates the original array
}
const activeUsers = getActiveUsers()
const sorted = sortUsersByAge(activeUsers) // activeUsers is now also sortedCorrect (readonly prevents accidental mutation):
function sortUsersByAge(users: readonly User[]) {
return [...users].sort((a, b) => a.age - b.age) // Copy first
}
const activeUsers = getActiveUsers()
const sorted = sortUsersByAge(activeUsers) // activeUsers unchangedNote: Use Readonly<T> for objects and readonly T[] or ReadonlyArray<T> for arrays. Prefer ReadonlyMap and ReadonlySet for collections.
Use satisfies for Config Objects Instead of Type Annotations
Type annotations widen literals to their base types, losing precise inference. The satisfies operator validates conformance while preserving the exact literal types, enabling autocomplete and type-safe property access.
Incorrect (annotation widens literal types):
interface RouteConfig {
[path: string]: { method: "GET" | "POST"; auth: boolean }
}
const routes: RouteConfig = {
"/users": { method: "GET", auth: true },
"/login": { method: "POST", auth: false },
}
routes["/users"].method // Type: "GET" | "POST" — lost the literalCorrect (satisfies preserves literals):
interface RouteConfig {
[path: string]: { method: "GET" | "POST"; auth: boolean }
}
const routes = {
"/users": { method: "GET", auth: true },
"/login": { method: "POST", auth: false },
} satisfies RouteConfig
routes["/users"].method // Type: "GET" — literal preservedReference: TypeScript 4.9 Release Notes
Avoid Deeply Recursive Type Definitions
Recursive types that nest beyond ~50 levels cause exponential compiler work, IDE lag, and cryptic "Type instantiation is excessively deep" errors. Flatten recursive types or add explicit depth limits.
Incorrect (unbounded recursion, compiler chokes):
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
// Applying both to a deep nested config type explodes compiler memory
type Config = DeepReadonly<DeepPartial<AppConfig>>Correct (bounded recursion with depth limit):
type DeepPartial<T, Depth extends number[] = []> =
Depth["length"] extends 5
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartial<T[K], [...Depth, 0]>
: T[K]
}
// Or simply avoid deep recursion — flatten instead
type ShallowConfig = Partial<Pick<AppConfig, "database">> & {
database?: Partial<AppConfig["database"]>
}When NOT to use this pattern:
- Simple single-level recursion (e.g., linked list types) is fine
Reference: TypeScript Performance Wiki
Use Base Types Instead of Large Union Types
When a union type has many members, the compiler must compare each member pairwise to eliminate redundancy -- an O(n²) operation. For unions exceeding ~20 members, use a base type with shared properties instead.
Incorrect (large union, quadratic comparisons):
type ApiEndpoint =
| { path: "/users"; method: "GET"; response: User[] }
| { path: "/users/:id"; method: "GET"; response: User }
| { path: "/users"; method: "POST"; response: User }
| { path: "/orders"; method: "GET"; response: Order[] }
| { path: "/orders/:id"; method: "GET"; response: Order }
// ... 50 more endpoints
// Compiler does n² comparisons on every type checkCorrect (base interface with generic parameter):
interface ApiEndpoint<TPath extends string, TMethod extends string, TResponse> {
path: TPath
method: TMethod
response: TResponse
}
interface EndpointMap {
"GET /users": ApiEndpoint<"/users", "GET", User[]>
"GET /users/:id": ApiEndpoint<"/users/:id", "GET", User>
"POST /users": ApiEndpoint<"/users", "POST", User>
"GET /orders": ApiEndpoint<"/orders", "GET", Order[]>
}
type Endpoint = EndpointMap[keyof EndpointMap]Reference: TypeScript Performance Wiki - Preferring Base Types Over Unions
Add Explicit Return Types to Exported Functions
Without explicit return types, the compiler must re-infer return types from function bodies on every compilation and when generating declaration files. Explicit return types on public API boundaries make .d.ts output predictable and speed up incremental builds — and they are mandatory once you adopt isolatedDeclarations (see `compile-isolated-declarations`), which generates declarations per file without checking the whole program.
Incorrect (compiler re-infers on every build):
export function createOrderSummary(order: Order) {
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0)
const tax = subtotal * order.taxRate
return {
orderId: order.id,
subtotal,
tax,
total: subtotal + tax,
itemCount: order.items.length,
}
// Compiler must analyze body to determine return type
}Correct (return type explicit, faster builds):
interface OrderSummary {
orderId: string
subtotal: number
tax: number
total: number
itemCount: number
}
export function createOrderSummary(order: Order): OrderSummary {
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0)
const tax = subtotal * order.taxRate
return {
orderId: order.id,
subtotal,
tax,
total: subtotal + tax,
itemCount: order.items.length,
}
}Note: Internal/private functions benefit less — focus on exported APIs and module boundaries. Exception: generic functions where inference carries type parameters through (see generic-return-type-inference).
Reference: TypeScript Performance Wiki - Using Type Annotations
Enable isolatedDeclarations for Parallel Declaration Emit
isolatedDeclarations (TS 5.5) requires every exported value to have an explicit, locally-inferable type, which lets build tools generate .d.ts files from a single file without type-checking the whole program. In a monorepo this turns declaration emit into a parallelizable, per-file step and removes the cross-package type-check bottleneck.
Incorrect (inferred export types — emit must check the whole program):
// tsconfig: no isolatedDeclarations
export function buildClient(config: ClientConfig) {
return { send: (req: ApiRequest) => fetch(config.url, req) }
// .d.ts emit must re-infer this return type across imported modules
}Correct (explicit export types — emittable in isolation):
// tsconfig: "isolatedDeclarations": true
export function buildClient(config: ClientConfig): ApiClient {
return { send: (req: ApiRequest) => fetch(config.url, req) }
}It enforces `compile-explicit-return-types` at the compiler level and pairs with `compile-project-references` for parallel monorepo builds.
Reference: TypeScript 5.5 — Isolated Declarations
Use Project References for Monorepo Builds
Project references split a monorepo into independently compilable units. Each project emits declarations once and downstream projects reference those declarations instead of re-type-checking source files. This enables parallel compilation and caching.
Incorrect (single tsconfig compiles everything):
{
"compilerOptions": {
"target": "ES2024",
"strict": true,
"outDir": "dist"
},
"include": ["src/**/*", "packages/**/*"]
}Correct (project references with composite projects):
{
"compilerOptions": {
"target": "ES2024",
"strict": true
},
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}Benefits:
tsc --buildonly recompiles changed projects- Parallel compilation across independent projects
- IDE navigation works across project boundaries with declaration maps
Combine with `compile-isolated-declarations` so each project's .d.ts files emit per file without checking the whole program.
Reference: TypeScript Handbook - Project References
Model Domain Errors as Discriminated Unions
String-based error codes and generic Error subclasses lose type information about error-specific data. Discriminated unions encode both the error kind and its associated data, enabling precise handling.
Incorrect (string codes, loose error data):
class AppError extends Error {
constructor(
public code: string,
message: string,
public details?: Record<string, unknown>
) {
super(message)
}
}
function handlePaymentError(error: AppError) {
if (error.code === "insufficient_funds") {
const shortfall = error.details?.shortfall as number // Unsafe cast
}
}Correct (discriminated union, typed error data):
type PaymentError =
| { type: "insufficient_funds"; shortfall: number; currency: string }
| { type: "card_declined"; reason: string; retryable: boolean }
| { type: "fraud_detected"; transactionId: string }
function handlePaymentError(error: PaymentError) {
if (error.type === "insufficient_funds") {
showShortfall(error.shortfall, error.currency) // Fully typed
}
}Use Exhaustive Checks for Typed Error Variants
Model domain errors as a discriminated union and use exhaustive switch statements to handle each variant. When a new error type is added, the compiler flags every unhandled location.
Incorrect (generic error, handler guesses):
function handleError(error: Error) {
if (error.message.includes("not found")) {
showNotFound()
} else if (error.message.includes("unauthorized")) {
redirectToLogin()
} else {
showGenericError() // New error types silently fall here
}
}Correct (discriminated error union, exhaustive):
type AppError =
| { type: "not_found"; resourceId: string }
| { type: "unauthorized"; requiredRole: string }
| { type: "validation"; fields: string[] }
function assertNever(value: never): never {
throw new Error(`Unhandled error type: ${JSON.stringify(value)}`)
}
function handleError(error: AppError) {
switch (error.type) {
case "not_found":
showNotFound(error.resourceId)
break
case "unauthorized":
redirectToLogin(error.requiredRole)
break
case "validation":
highlightFields(error.fields)
break
default:
assertNever(error) // Compile error if new type added but not handled
}
}Use Result Types Instead of Thrown Exceptions
Thrown exceptions bypass the type system — callers have no compile-time indication that a function can fail. A discriminated Result type makes success and failure explicit in the return type, forcing callers to handle both cases.
Incorrect (thrown exceptions invisible to type system):
function parseJson(raw: string): Config {
try {
return JSON.parse(raw)
} catch {
throw new Error("Invalid JSON") // Caller has no type-level warning
}
}
const config = parseJson(input) // Looks infallible — but throwsCorrect (Result type makes failure explicit):
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
function parseJson(raw: string): Result<Config> {
try {
return { ok: true, value: JSON.parse(raw) }
} catch (err) {
return { ok: false, error: new Error("Invalid JSON", { cause: err }) }
}
}
const result = parseJson(input)
if (!result.ok) {
console.error(result.error.message) // Must handle error
return
}
console.log(result.value) // Narrowed to ConfigWhen NOT to use this pattern:
- Truly exceptional situations (out of memory, network down) where recovery is impossible
- Infrastructure code where try/catch boundaries are already well-defined
Type Catch Clause Variables as unknown
Caught values in JavaScript can be anything — not just Error instances. TypeScript 4.4+ supports unknown in catch clauses (and useUnknownInCatchVariables enforces it). Always narrow the caught value before accessing properties.
Incorrect (assumes caught value is Error):
try {
await submitOrder(order)
} catch (err) {
console.error(err.message) // Runtime crash if err is a string or number
logError(err.stack) // .stack might not exist
}Correct (narrow before accessing properties):
try {
await submitOrder(order)
} catch (err: unknown) {
if (err instanceof Error) {
console.error(err.message)
logError(err.stack)
} else {
console.error("Unexpected error:", String(err))
}
}Note: Enable useUnknownInCatchVariables in tsconfig (included in strict since TS 4.4) to enforce this automatically.
Reference: TypeScript 4.4 - useUnknownInCatchVariables
Control Distributive Conditional Types
Conditional types distribute over union members by default when the checked type is a naked type parameter. This causes unexpected behavior when you want to check the union as a whole. Wrap both sides of extends in brackets to disable distribution.
Incorrect (distributes unexpectedly over union):
type IsArray<T> = T extends unknown[] ? true : false
type Result = IsArray<string | number[]>
// Distributes: IsArray<string> | IsArray<number[]>
// Result: false | true = boolean — not what you wantCorrect (brackets disable distribution):
type IsArray<T> = [T] extends [unknown[]] ? true : false
type Result = IsArray<string | number[]>
// Checks (string | number[]) as a whole
// Result: false — correctReference: TypeScript Handbook - Distributive Conditional Types
Constrain Generics Minimally
Over-constraining generics couples them to specific implementations and limits reuse. Constrain to the minimal interface needed — use the properties you actually access, not the full type.
Incorrect (over-constrained to specific type):
function getDisplayName<T extends User>(entity: T): string {
return entity.name
}
// Cannot use with Organization, Team, or any other named entity
getDisplayName(organization) // Error: Organization not assignable to UserCorrect (constrained to minimal interface):
function getDisplayName<T extends { name: string }>(entity: T): string {
return entity.name
}
getDisplayName(user) // OK
getDisplayName(organization) // OK
getDisplayName(team) // OK — any object with .name worksNote: This follows the Interface Segregation Principle — depend on the smallest interface that satisfies your needs.
Build Custom Mapped Types for Repeated Transformations
When you find yourself writing multiple interfaces that are variations of the same shape (nullable version, event version, partial version), extract a mapped type utility. This keeps types in sync as the base type evolves.
Incorrect (manual duplication drifts over time):
interface User {
id: string
name: string
email: string
}
interface UserUpdate {
id?: string
name?: string
email?: string
}
interface UserEvents {
onIdChange: (value: string) => void
onNameChange: (value: string) => void
onEmailChange: (value: string) => void
// Adding a field to User? Must update here too
}Correct (mapped types derive from source):
interface User {
id: string
name: string
email: string
}
type UserUpdate = Partial<User>
type EventHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (value: T[K]) => void
}
type UserEvents = EventHandlers<User>
// Automatically includes onIdChange, onNameChange, onEmailChange
// Adding a field to User? UserEvents updates automaticallyReference: TypeScript Handbook - Mapped Types
Preserve Return Type Inference in Generic Functions
When a generic function returns a computed type, annotating the return type with a wide interface instead of letting TypeScript infer the precise type loses generic precision at call sites.
Incorrect (explicit return type widens generic away):
interface Config {
host: string
port: number
debug: boolean
}
function getConfig(key: keyof Config): Config[keyof Config] {
const config: Config = loadConfig()
return config[key]
}
const port = getConfig("port") // Type: string | number | boolean — widenedCorrect (generic parameter preserves precision):
interface Config {
host: string
port: number
debug: boolean
}
function getConfig<K extends keyof Config>(key: K) {
const config: Config = loadConfig()
return config[key] // Return type inferred as Config[K]
}
const port = getConfig("port") // Type: number
const host = getConfig("host") // Type: string
const debug = getConfig("debug") // Type: booleanNote: For exported non-generic functions, explicit return types are preferred for compiler performance (see compile-explicit-return-types). This rule applies specifically to generic functions where inference carries type parameters through.
Use Const Type Parameters for Literal Inference
The const modifier on type parameters (TS 5.0+) makes the compiler infer literal types by default, removing the need for as const at every call site. Use it for functions that need to preserve exact shapes.
Incorrect (callers must remember as const):
function createRoute<T extends readonly string[]>(methods: T, path: string) {
return { methods, path }
}
const route = createRoute(["GET", "POST"], "/users")
// route.methods type: string[] — literals lost
const route2 = createRoute(["GET", "POST"] as const, "/users")
// route2.methods type: readonly ["GET", "POST"] — but callers must rememberCorrect (const type parameter infers literals):
function createRoute<const T extends readonly string[]>(methods: T, path: string) {
return { methods, path }
}
const route = createRoute(["GET", "POST"], "/users")
// route.methods type: readonly ["GET", "POST"] — automaticReference: TypeScript 5.0 - const Type Parameters
Prefer Erasable Syntax Over Enums and Namespaces
TypeScript's runtime-emitting constructs — enum, namespace/module, parameter properties, and legacy experimentalDecorators — cannot be erased to plain JavaScript; they require code generation. Node.js native type-stripping (23.6+) and TypeScript's erasableSyntaxOnly flag (5.8) reject them, and TypeScript 6.0 turns the old module Foo {} form into an error. Code built from erasable syntax runs unchanged anywhere types are simply stripped.
Incorrect (enum + namespace — non-erasable, fails type-stripping):
export enum LogLevel { Debug, Info, Warn, Error }
export namespace Logger {
export function format(level: LogLevel): string {
return LogLevel[level]
}
}Correct (const object + module exports — fully erasable):
export const LogLevel = {
Debug: 0, Info: 1, Warn: 2, Error: 3,
} as const
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
export function format(level: LogLevel): string {
return Object.keys(LogLevel)[level]
}Set "erasableSyntaxOnly": true in tsconfig.json to catch non-erasable syntax at compile time. If you genuinely need decorators, use the Stage 3 standard form (TS 5.0), not experimentalDecorators. See `perf-union-literals-over-enums` for the enum-to-union refactor.
Reference: TypeScript 5.8 — erasableSyntaxOnly
Use with Import Attributes Instead of assert
The import assert syntax for typed module imports was renamed to with when import attributes reached Stage 3. TypeScript 6.0 deprecates assert — including inside dynamic import() calls — ahead of its removal in 7.0. The with form is the standardized spelling that current runtimes and bundlers understand.
Incorrect (deprecated assert syntax):
import config from "./config.json" assert { type: "json" }
const data = await import("./data.json", { assert: { type: "json" } })Correct (with import attributes):
import config from "./config.json" with { type: "json" }
const data = await import("./data.json", { with: { type: "json" } })Reference: TypeScript 6.0 release notes
Use NoInfer to Control Type Parameter Inference
NoInfer<T> (TS 5.4+) marks a type parameter position as non-inferring. Use it when a generic function has multiple parameters and you want inference to come from one specific parameter, not all of them.
Incorrect (default infers from all positions, widening the type):
function createSignal<T>(initial: T, fallback: T): T {
return initial ?? fallback
}
const signal = createSignal("active", "unknown")
// T inferred as "active" | "unknown" — but "unknown" should not widen TCorrect (NoInfer prevents inference from fallback):
function createSignal<T>(initial: T, fallback: NoInfer<T>): T {
return initial ?? fallback
}
const signal = createSignal("active", "unknown")
// T inferred as "active" — fallback must be assignable to "active"
// Compile error: "unknown" not assignable to "active"Reference: TypeScript 5.4 - NoInfer
Use Template Literal Types for String Patterns
Template literal types (TS 4.1+) encode string patterns directly in the type system. Use them to validate format constraints — event names, CSS units, API paths — without runtime checks.
Incorrect (plain string accepts any value):
function on(eventName: string, handler: () => void) {
// ...
}
on("click", handleClick)
on("clck", handleClick) // Typo — no errorCorrect (template literal constrains format):
type DomEvent = "click" | "focus" | "blur" | "input" | "change"
type EventHandler = `on${Capitalize<DomEvent>}`
function registerHandler(name: EventHandler, handler: () => void) {
// ...
}
registerHandler("onClick", handleClick)
registerHandler("onClck", handleClick) // Compile errorAlternative (dynamic key patterns):
type CssUnit = `${number}${"px" | "rem" | "em" | "%"}`
function setWidth(value: CssUnit) { /* ... */ }
setWidth("100px") // OK
setWidth("2.5rem") // OK
setWidth("100") // Compile error — missing unitReference: TypeScript 4.1 - Template Literal Types
Use the using Keyword for Resource Cleanup
The using keyword (TS 5.2+, Stage 3 Explicit Resource Management) automatically calls [Symbol.dispose]() when a variable goes out of scope. This eliminates try/finally boilerplate and prevents resource leaks from early returns or exceptions.
Incorrect (manual cleanup, leaks on early return):
function processFile(path: string) {
const handle = openFile(path)
const content = handle.read()
if (!content) return null // Leak: handle.close() is never called
const result = parseContent(content)
handle.close()
return result
}Correct (using guarantees cleanup):
function processFile(path: string) {
using handle = openFile(path) // Disposed automatically at end of scope
const content = handle.read()
if (!content) return null // handle[Symbol.dispose]() still called
return parseContent(content)
}
function openFile(path: string): Disposable & FileHandle {
const handle = fs.openSync(path, "r")
return {
read: () => fs.readFileSync(handle, "utf-8"),
close: () => fs.closeSync(handle),
[Symbol.dispose]() { fs.closeSync(handle) },
}
}Note: Use await using for async cleanup with [Symbol.asyncDispose]().
Reference: TypeScript 5.2 - using Declarations
Enable verbatimModuleSyntax for Explicit Import Types
verbatimModuleSyntax (TS 5.0+) requires import type for type-only imports and emits imports exactly as written. This eliminates accidental runtime imports of type-only modules, improves tree-shaking, and makes the intent of every import explicit. It also keeps type-only imports cleanly erasable, which matters under Node.js type-stripping and TypeScript 6.0's module: esnext / strict-by-default posture.
Incorrect (type import looks like value import):
// tsconfig.json: no verbatimModuleSyntax
import { User, createUser } from "./user"
// User is only used as a type, but emitted as runtime import
// Bundler must figure out it's unused — fragile
function greet(user: User) {
return `Hello, ${user.name}`
}Correct (explicit import type):
// tsconfig.json: "verbatimModuleSyntax": true
import type { User } from "./user"
import { createUser } from "./user"
// Clear intent: User is compile-time only, createUser is runtime
function greet(user: User) {
return `Hello, ${user.name}`
}Reference: TypeScript 5.0 - verbatimModuleSyntax
Use Assertion Functions for Precondition Checks
Assertion functions combine runtime validation with compile-time narrowing. After the assertion call, TypeScript narrows the type for the rest of the scope — no need for separate if checks or as casts.
Incorrect (manual check-and-throw repeated everywhere):
function processOrder(order: Order | null) {
if (order === null) {
throw new Error("Order is required")
}
sendConfirmation(order) // Narrowed, but pattern duplicated across functions
}
function shipOrder(order: Order | null) {
if (order === null) {
throw new Error("Order is required")
}
createShipment(order) // Same check-and-throw copied again
}Correct (assertion function, reusable and composable):
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`${name} is required`)
}
}
function processOrder(order: Order | null) {
assertDefined(order, "order")
sendConfirmation(order) // Narrowed to Order
}
function shipOrder(order: Order | null) {
assertDefined(order, "order")
createShipment(order) // Same narrowing, no duplication
}Note: Assertion functions work with asserts value is T and asserts condition. Use them for shared preconditions across multiple functions.
Reference: TypeScript 3.7 - Assertion Functions
Write Custom Type Guards Instead of Type Assertions
Type assertions (as) silence the compiler without runtime verification. Replace them with a function that actually checks the value: TypeScript 5.5+ infers the type predicate from a guard whose body is a single boolean return, so a plain boolean return narrows callers without writing is at all. Reserve an explicit is annotation for guards with multiple returns or those you want to document as a contract — for example exported guards.
Incorrect (assertion trusts the developer, not the runtime):
interface ApiResponse {
status: number
payload: unknown
}
function handleSuccess(response: ApiResponse) {
const order = response.payload as Order // Unsafe — no runtime check
console.log(order.total)
}Correct (TS 5.5+ infers the predicate from a single-return body):
function isOrder(value: unknown) {
return (
typeof value === "object" && value !== null &&
"total" in value && typeof value.total === "number"
)
}
// Inferred signature: (value: unknown) => value is { total: number }
function handleSuccess(response: ApiResponse) {
if (!isOrder(response.payload)) throw new Error("Invalid payload")
console.log(response.payload.total) // Narrowed — no `as`
}Annotate value is Order explicitly when the body branches across several returns (inference only fires on a single return) or to lock the public contract of an exported guard.
Reference: TypeScript 5.5 — Inferred Type Predicates
Eliminate as Casts with Proper Narrowing Chains
Every as cast is a promise from developer to compiler with zero runtime verification. Replace casts with narrowing chains: typeof, instanceof, in, discriminant checks, and custom guards narrow types safely through control flow.
Incorrect (assertion chain, no runtime safety):
function parseConfig(raw: unknown): AppConfig {
const config = raw as Record<string, unknown>
return {
port: config.port as number,
host: config.host as string,
debug: config.debug as boolean,
}
}Correct (narrowing chain, runtime safe):
function parseConfig(raw: unknown): AppConfig {
if (typeof raw !== "object" || raw === null) {
throw new Error("Config must be an object")
}
const config = raw as Record<string, unknown>
if (typeof config.port !== "number") throw new Error("port must be a number")
if (typeof config.host !== "string") throw new Error("host must be a string")
return {
port: config.port,
host: config.host,
debug: typeof config.debug === "boolean" ? config.debug : false,
}
}Alternative (use a validation library for complex schemas):
import { z } from "zod"
const AppConfigSchema = z.object({
port: z.number(),
host: z.string(),
debug: z.boolean().default(false),
})
function parseConfig(raw: unknown): AppConfig {
return AppConfigSchema.parse(raw) // Throws with detailed errors
}Enforce Exhaustive Switch with never
When switching on a discriminated union, assign the default case to never. If a new member is added to the union but the switch is not updated, the compiler reports an error immediately — no silent fallthrough.
Incorrect (default swallows new variants silently):
type PaymentMethod = "card" | "bank" | "crypto"
function processFee(method: PaymentMethod): number {
switch (method) {
case "card": return 0.029
case "bank": return 0.005
default: return 0 // "crypto" silently returns 0, new methods too
}
}Correct (never catches unhandled variants):
type PaymentMethod = "card" | "bank" | "crypto"
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`)
}
function processFee(method: PaymentMethod): number {
switch (method) {
case "card": return 0.029
case "bank": return 0.005
case "crypto": return 0.015
default: return assertNever(method) // Compile error if a case is missing
}
}The same assertNever guard works for if/else if chains: end the chain with assertNever(value) so an added union member becomes a compile error instead of a silent fallthrough.
Narrow with the in Operator for Interface Unions
The in operator narrows union types by checking for the presence of a property. This is simpler than writing a full type guard function and works well when discriminated unions lack a shared discriminant field.
Incorrect (unsafe cast to access variant-specific properties):
interface EmailNotification {
email: string
subject: string
body: string
}
interface SmsNotification {
phone: string
message: string
}
type Notification = EmailNotification | SmsNotification
function send(notification: Notification) {
const email = (notification as EmailNotification).email // Unsafe cast
if (email) {
sendEmail(email, (notification as EmailNotification).subject)
}
}Correct (in operator narrows safely):
interface EmailNotification {
email: string
subject: string
body: string
}
interface SmsNotification {
phone: string
message: string
}
type Notification = EmailNotification | SmsNotification
function send(notification: Notification) {
if ("email" in notification) {
sendEmail(notification.email, notification.subject) // Narrowed to EmailNotification
} else {
sendSms(notification.phone, notification.message) // Narrowed to SmsNotification
}
}Avoid the delete Operator on Objects
The delete operator triggers V8 hidden class transitions, converting the object from a fast "struct-like" representation to a slow dictionary mode. Use destructuring with rest or set the property to undefined instead.
Incorrect (delete triggers deoptimization):
function sanitizeUser(user: User & { password?: string }) {
delete user.password // V8 transitions object to dictionary mode
return user
}Correct (destructure and omit):
function sanitizeUser(user: User & { password?: string }) {
const { password, ...sanitized } = user
return sanitized // New object, original unchanged, V8 stays optimized
}Alternative (set to undefined if mutation is acceptable):
function sanitizeUser(user: User & { password?: string }) {
user.password = undefined // No hidden class change
return user
}Use Map and Set Over Plain Objects for Dynamic Collections
Plain objects used as dictionaries have string-only keys, prototype pollution risks, and slower iteration. Map and Set provide O(1) operations with any key type, guaranteed insertion order, and better memory characteristics for frequently changing collections.
Incorrect (plain object as dictionary):
const userSessions: Record<string, Session> = {}
function addSession(userId: string, session: Session) {
userSessions[userId] = session
}
function getSession(userId: string): Session | undefined {
return userSessions[userId] // No distinction between "missing" and "set to undefined"
}
function countSessions(): number {
return Object.keys(userSessions).length // Creates intermediate array
}Correct (Map for typed key-value collections):
const userSessions = new Map<string, Session>()
function addSession(userId: string, session: Session) {
userSessions.set(userId, session)
}
function getSession(userId: string): Session | undefined {
return userSessions.get(userId)
}
function countSessions(): number {
return userSessions.size // O(1), no intermediate array
}Benefits:
- Any key type (objects, symbols, numbers — not just strings)
.sizeis O(1) vsObject.keys().lengthwhich is O(n)- No prototype pollution risk
- Guaranteed iteration order
Use Object.freeze with as const for True Immutability
as const only provides compile-time readonly guarantees — JavaScript code or untyped consumers can still mutate the object. Combine Object.freeze for runtime protection with as const for compile-time literal inference.
Incorrect (as const alone, runtime mutable):
const permissions = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
} as const
// TypeScript prevents this, but runtime JS doesn't:
// (permissions as any).admin.push("sudo")Correct (frozen at runtime and compile time):
const permissions = Object.freeze({
admin: Object.freeze(["read", "write", "delete"] as const),
editor: Object.freeze(["read", "write"] as const),
viewer: Object.freeze(["read"] as const),
})
// Runtime: Object.freeze prevents mutation
// Compile-time: as const preserves literal typesWhen NOT to use this pattern:
- Hot paths where the freeze overhead matters (rare — freeze is cheap)
- Objects that genuinely need to be mutable
Avoid Object.keys Type Widening
Object.keys() returns string[], not (keyof T)[], because TypeScript's structural type system allows objects to have extra properties. Use type-safe alternatives to iterate over known keys without losing type information.
Incorrect (Object.keys returns string[], loses key types):
interface ThemeColors {
primary: string
secondary: string
accent: string
}
const theme: ThemeColors = { primary: "#000", secondary: "#333", accent: "#0070f3" }
Object.keys(theme).forEach(key => {
const color = theme[key] // Error: string can't index ThemeColors
})Correct (typed key iteration):
interface ThemeColors {
primary: string
secondary: string
accent: string
}
const theme: ThemeColors = { primary: "#000", secondary: "#333", accent: "#0070f3" }
function typedKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]
}
typedKeys(theme).forEach(key => {
const color = theme[key] // Type: string — works correctly
})Alternative (for-in with type guard):
for (const key in theme) {
if (key in theme) {
const color = theme[key as keyof ThemeColors]
}
}Note: The string[] return type is intentional — TypeScript can't guarantee no extra properties exist due to structural typing. Use the typed helper only when you control the object's shape.
Use Union Literals Instead of Enums
The decisive problem with enum is no longer bundle size — it is that enums emit a runtime lookup object, so they are non-erasable. They error under erasableSyntaxOnly (TS 5.8) and will not run under Node.js native type-stripping. Union literal types exist only at compile time: nothing to emit, nothing to strip, and simpler debugging output.
Incorrect (enum emits a runtime object; fails type-stripping):
enum OrderStatus {
Pending = "pending",
Processing = "processing",
Shipped = "shipped",
Delivered = "delivered",
}
function isComplete(status: OrderStatus): boolean {
return status === OrderStatus.Delivered
}
// Emits a runtime IIFE — rejected when types are stripped, not compiledCorrect (union literal, fully erasable):
type OrderStatus = "pending" | "processing" | "shipped" | "delivered"
function isComplete(status: OrderStatus): boolean {
return status === "delivered"
}
// Erases to: function isComplete(status) { return status === "delivered" }When you need the runtime values (iteration, reverse lookup), use an erasable as const object instead of an enum:
const OrderStatus = {
Pending: "pending", Processing: "processing",
Shipped: "shipped", Delivered: "delivered",
} as const
type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus]See `modern-erasable-syntax` for the broader erasability rule.
Reference: TypeScript 5.8 — erasableSyntaxOnly
Avoid the {} Type — It Means Non-Nullish
The {} type does not mean "empty object" — it means "any value that is not null or undefined." Strings, numbers, booleans, and arrays all satisfy {}. Use Record<string, never> for truly empty objects or object for any non-primitive.
Incorrect ({} accepts everything non-nullish):
function processMetadata(meta: {}) {
// Intended: empty object or object with unknown keys
// Actually: accepts string, number, boolean, array...
}
processMetadata("hello") // Compiles — string satisfies {}
processMetadata(42) // Compiles — number satisfies {}
processMetadata([1, 2, 3]) // Compiles — array satisfies {}Correct (use the right type for your intent):
// For "any non-primitive value" (objects, arrays, functions):
function processMetadata(meta: object) { /* ... */ }
// For "empty object with no properties":
function processMetadata(meta: Record<string, never>) { /* ... */ }
// For "object with unknown string keys":
function processMetadata(meta: Record<string, unknown>) { /* ... */ }
processMetadata("hello") // Compile error with all three optionsUnderstand Excess Property Checks on Object Literals
TypeScript only checks for extra properties when assigning object literals directly. Passing through an intermediate variable bypasses this check due to structural typing. Understanding this quirk prevents both false confidence and unexpected errors.
Incorrect (intermediate variable bypasses check):
interface CreateUserInput {
name: string
email: string
}
const input = {
name: "Alice",
email: "alice@example.com",
role: "admin", // Extra property — no error through intermediate variable
}
function createUser(input: CreateUserInput) { /* ... */ }
createUser(input) // Compiles — "role" silently ignoredCorrect (direct literal catches extra properties):
interface CreateUserInput {
name: string
email: string
}
function createUser(input: CreateUserInput) { /* ... */ }
createUser({
name: "Alice",
email: "alice@example.com",
role: "admin", // Compile error: 'role' does not exist in type 'CreateUserInput'
})Note: Use satisfies to get excess property checking even with intermediate variables:
const input = {
name: "Alice",
email: "alice@example.com",
role: "admin", // Error with satisfies
} satisfies CreateUserInputReference: TypeScript Handbook - Excess Property Checks
Guard Against Structural Typing Escape Hatches
TypeScript's structural type system means any object with the right properties satisfies an interface — even if it has extra properties. This is by design, but it can cause data leaks when spreading or serializing objects that carry more than expected.
Incorrect (extra properties leak through structural compatibility):
interface PublicProfile {
name: string
avatar: string
}
function toPublicProfile(user: User): PublicProfile {
return user // Compiles — User has name and avatar (plus email, password, etc.)
}
const profile = toPublicProfile(currentUser)
JSON.stringify(profile) // Includes email, password — data leak!Correct (explicitly pick properties):
interface PublicProfile {
name: string
avatar: string
}
function toPublicProfile(user: User): PublicProfile {
return {
name: user.name,
avatar: user.avatar,
}
}
const profile = toPublicProfile(currentUser)
JSON.stringify(profile) // Only name and avatar — safeNote: This is especially important at API boundaries, logging, and serialization points where extra properties become visible outside the type system.
Use Variance Annotations to Document Generic Intent
TypeScript 4.7+ supports in and out variance annotations on type parameters. Their value is documentation and correctness, not speed: they state whether a parameter is covariant (produced, out), contravariant (consumed, in), or invariant, and the compiler errors if a later edit violates the declared variance. Do not add them for performance — the official guidance is that they help only "in extraordinarily complex types" and only after profiling proves a bottleneck.
Without annotation (variance is inferred, intent undocumented):
interface Producer<T> {
produce(): T
}
interface Consumer<T> {
consume(item: T): void
}
interface Transformer<TInput, TOutput> {
transform(input: TInput): TOutput
}
// Variance is inferred; a future edit could change it unnoticedWith annotation (intent documented and enforced):
interface Producer<out T> {
produce(): T
}
interface Consumer<in T> {
consume(item: T): void
}
interface Transformer<in TInput, out TOutput> {
transform(input: TInput): TOutput
}
// A method that violates `in`/`out` now fails to compileWhen NOT to use: Most generic interfaces — TypeScript infers variance correctly and annotations add noise. Reach for them only on widely-shared library interfaces where the intended variance is a contract, or after profiling identifies a genuinely expensive type.
Reference: TypeScript 4.7 - Variance Annotations
Type Props Directly Instead of React.FC
React.FC blocks generic components and obscures the children contract — historically it forced a children prop the component may not render. Typing the props parameter directly is simpler, makes children opt-in, and lets a component be generic.
Incorrect (React.FC hides the children contract and can't be generic):
const UserCard: React.FC<UserCardProps> = ({ user }) => {
return <div>{user.name}</div>
}
// children is silently accepted even though UserCard never renders it
// Cannot write `const List: React.FC<ListProps<T>>` — FC is not genericCorrect (plain function; children opt-in; generics work):
interface UserCardProps {
user: User
children?: React.ReactNode // declared only because this card renders it
}
function UserCard({ user, children }: UserCardProps) {
return <div>{user.name}{children}</div>
}
function List<T>({ items, renderItem }: ListProps<T>) {
return <ul>{items.map(renderItem)}</ul>
}Type children as React.ReactNode (the widest renderable type), not React.ReactElement/React.JSX.Element, which reject strings, numbers, arrays, and null.
Reference: React — Using TypeScript
Model Mutually-Exclusive Props as Discriminated Unions
When a component renders different shapes for different states, all-optional props let callers build impossible combinations (status="error" with no error, or with stale data). A discriminated union on a literal field ties each state to exactly the props it needs, so illegal combinations fail to type-check and narrowing removes non-null assertions.
Incorrect (all-optional; impossible states compile):
interface UserPanelProps {
status: "loading" | "error" | "success"
data?: User
error?: Error
}
function UserPanel({ status, data }: UserPanelProps) {
if (status === "success") return <Profile user={data!} /> // data could be undefined
// <UserPanel status="error" /> compiles with no error object
return null
}Correct (each state carries its own props):
type UserPanelProps =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: User }
function UserPanel(props: UserPanelProps) {
switch (props.status) {
case "loading": return <Spinner />
case "error": return <Alert message={props.error.message} />
case "success": return <Profile user={props.data} /> // narrowed, no `!`
}
}The same shape types useReducer actions — see `arch-discriminated-unions`.
Reference: React — Using TypeScript: useReducer
Type Event Handlers with React Synthetic Event Types
React dispatches its own SyntheticEvent, not the DOM Event. Typing a handler parameter as any (or the wrong DOM event) loses the typed target/currentTarget and lets typos through. Use the element-parameterized React event types for handlers defined separately; write the handler inline when you want the event type inferred from the JSX attribute.
Incorrect (any erases the event shape):
function handleChange(event: any) {
setQuery(event.target.value) // value is any; typos go uncaught
}
return <input onChange={handleChange} />Correct (parameterized synthetic events; inline handlers infer):
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
setQuery(event.target.value) // value: string
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
}
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
<button onClick={(event) => console.log(event.currentTarget.name)}>Go</button>
</form>
)Extend Native Element Props Instead of Redeclaring Them
Hand-listing className, onClick, disabled, aria-*, etc. on a wrapper component drifts from the DOM definitions and silently drops attributes consumers expect to pass through. React.ComponentPropsWithRef<"button"> inherits every attribute a real <button> accepts (including ref in React 19), so the wrapper stays in sync automatically.
Incorrect (redeclares a subset; the rest can't be passed):
interface ButtonProps {
onClick: () => void
className?: string
disabled?: boolean
// no type, name, form, aria-* … consumers cannot forward them
}
function Button({ onClick, className, disabled }: ButtonProps) {
return <button onClick={onClick} className={className} disabled={disabled} />
}Correct (inherit all native props, add your own):
interface ButtonProps extends React.ComponentPropsWithRef<"button"> {
variant: "primary" | "secondary"
}
function Button({ variant, className, ...rest }: ButtonProps) {
return <button className={`btn-${variant} ${className ?? ""}`} {...rest} />
}Use React.ComponentPropsWithoutRef<"button"> when the component does not forward a ref, and React.ComponentProps<typeof OtherComponent> to mirror another component's props.
Reference: React TypeScript Cheatsheet — Wrapping HTML elements
Type useState and useRef for Nullable and Mutable State
useState infers from its initial value, which is right when the value is concrete but wrong when state starts empty: useState(null) infers the type null and rejects every later assignment. Give the explicit union when state will hold more than its initial value. In React 19 useRef requires an initial argument, so DOM refs are useRef<T>(null).
Incorrect (inferred null; ref with no argument):
const [user, setUser] = useState(null)
setUser(fetchedUser) // Error: User is not assignable to null
const inputRef = useRef<HTMLInputElement>() // Error in React 19: expected 1 argumentCorrect (explicit union; ref initialized):
const [user, setUser] = useState<User | null>(null)
setUser(fetchedUser) // OK
const inputRef = useRef<HTMLInputElement>(null)
inputRef.current?.focus()Let component return types infer; when you must annotate one, use React.JSX.Element — the global JSX namespace was removed from @types/react 19.
Reference: React — Using TypeScript: Hooks
Pass ref as a Prop Instead of forwardRef
React 19 lets function components receive ref as an ordinary prop and deprecates forwardRef. Declaring ref in the props type drops the generic-argument gymnastics of forwardRef and types exactly like any other prop.
Incorrect (forwardRef wrapper, deprecated in React 19):
import { forwardRef } from "react"
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput({ placeholder }, ref) {
return <input ref={ref} placeholder={placeholder} />
},
)Correct (ref is a normal prop):
interface SearchInputProps {
placeholder: string
ref?: React.Ref<HTMLInputElement>
}
function SearchInput({ placeholder, ref }: SearchInputProps) {
return <input ref={ref} placeholder={placeholder} />
}For components that forward every native attribute, use React.ComponentPropsWithRef<"input"> so ref is typed automatically — see `tsx-extend-native-props`.
Reference: React 19 — ref as a prop
Related skills
How it compares
Choose typescript-refactor over generic lint skills when you need deep TypeScript 6.0 and React 19 typing guidance with ordered impact, not just formatting or ESLint autofixes.
FAQ
How many rules does typescript-refactor include?
typescript-refactor bundles 47 prioritized rules across 9 categories for TypeScript 6.0 and React 19 TSX. Rules span type architecture, narrowing, modern syntax, and compiler performance, each with examples and references in skill version 1.2.0.
Which TypeScript and React versions does typescript-refactor target?
typescript-refactor is current to TypeScript 6.0 and React 19. It documents satisfies, using, const type parameters, inferred type predicates, isolatedDeclarations, erasable syntax, and import attributes for agent-led refactors.
When should a developer invoke typescript-refactor?
typescript-refactor fits active refactors of production TS/TSX where types, narrowing, or React 19 patterns need hardening. Invoke it when an agent is modernizing components or modules and you want 47 ordered rules instead of one-off guesses.
Is Typescript Refactor safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.