ts-pattern-refactor

Installation
SKILL.md

ts-pattern Refactor

Convert error-prone conditional code to match().with().exhaustive() — without over-applying it.

When to Use

  • Sweeping a TS/React codebase for ts-pattern opportunities
  • A discriminated union ({ kind: ... }, status: 'a' | 'b' | ...) has grown new variants and the UI dispatch is scattered
  • Reviewing JSX with nested ternaries or && fragment chains keyed off the same discriminator

Judgment Criteria

The number of branches alone is not the trigger. Look at syntactic form × context.

Pattern Refactor? Why
JSX dispatch on a discriminator (renders different elements per variant) Yes JSX edits are visually noisy; ts-pattern keeps each branch's structure parallel
Nested ternary (a ? x : b ? y : z) Yes Hard to edit; format breaks easily on add/remove
Repeated {x === 'a' && ...}{x === 'b' && ...} chains on the same discriminator Yes Implicit fall-through bugs; ts-pattern enforces exhaustiveness
Discriminated union with 4+ variants, dispatched anywhere Yes .exhaustive() catches missing variants at compile time
Plain TS if (x.kind === 'a') return ...; if (x.kind === 'b') return ... No Single-condition if-chains are structurally hard to break — leave them
2-3 branch simple if/else or switch No Per react-rules.md: ts-pattern is for 4+ cases / discriminated unions
Single-case {status === 'x' && <Block />} No One condition is fine as &&

Rule of thumb: if a future variant addition could silently fall through the existing code, refactor. If the structure prevents that already, leave it.

Workflow

Phase 1 — Detect

Run these greps from repo root. Each surfaces a different smell:

# Nested ternaries — a ? b : c ? d : e
rg -n --type ts --type tsx '\?\s*[^?:]+:\s*[^?:]+\?\s*[^?:]+:' src/

# Repeated `===` checks on the same discriminator (status, kind, type)
rg -n --type tsx '(status|kind|type)\s*===\s*' src/ | awk -F: '{print $1":"$2}' | sort | uniq -c | sort -rn | head -20

# JSX `&&` chains on a discriminator (heuristic — review hits)
rg -n --type tsx '\{\s*\w+\s*===\s*[\x27"]\w+[\x27"]\s*&&' src/

# Existing ts-pattern usage (skip files that are already migrated)
rg -l 'from .ts-pattern.' src/

For discriminated-union sites, use Serena to find every dispatch:

mcp__serena__find_referencing_symbols { name_path: "UpdateStatus", relative_path: "src/shared/types.ts" }

Read ~/.claude/projects/<this-project>/memory/feedback_ts_pattern_threshold.md if present — it records earlier decisions on this same codebase.

Phase 2 — Apply

Use Context7 (mcp__context7__query-docs with library /gvergnaud/ts-pattern) for current API. Common conversions:

Nested ternary → match

// Before
const label = status === 'error' ? error
  : status === 'available' ? `Version ${v} available`
  : status === 'downloading' ? `Downloading ${v}...`
  : `Version ${v} ready`

// After
const label = match(status)
  .with('error', () => error)
  .with('available', () => `Version ${v} available`)
  .with('downloading', () => `Downloading ${v}...`)
  .with('ready', () => `Version ${v} ready to install`)
  .exhaustive()

JSX if-chain on discriminated union → match

// Before
if (content.kind === 'empty') return <EmptyState />
if (content.kind === 'binary') return <BinaryPlaceholder {...content} />
if (content.kind === 'image') return <img src={content.data.dataUrl} />
return <TextPreview content={content.data.content} />

// After
return match(content)
  .with({ kind: 'empty' }, () => <EmptyState />)
  .with({ kind: 'binary' }, ({ fileName, size }) => <BinaryPlaceholder fileName={fileName} size={size} />)
  .with({ kind: 'image' }, ({ data }) => <img src={data.dataUrl} alt={data.name} />)
  .with({ kind: 'text' }, ({ data }) => <TextPreview content={data.content} />)
  .exhaustive()

Multiple values, same branch:

.with('available', 'downloading', () => <Download className="..." />)

Add a one-line comment above each match() block stating why the call site is exhaustive (e.g. "narrowed to four visible phases above"). Future readers should see at a glance that adding a variant breaks compilation here.

Phase 3 — Verify

Run in parallel; each must pass:

pnpm typecheck   # exhaustiveness errors surface here
pnpm lint
pnpm test        # vitest run (includes browser mode)

If the project has e2e:

npx tsc -p e2e/tsconfig.json --noEmit  # e2e has its own tsconfig
pnpm test:e2e

Any failure → fix or revert that single file → re-run only the failing check.

Things NOT to Do

  • Don't refactor plain TS single-condition if-chains. They're structurally safe; ts-pattern just adds a dependency without preventing real bugs.
  • Don't add .otherwise(() => null) to silence exhaustiveness errors. That defeats the purpose. Either handle the variant or use .exhaustive() and let the compiler complain.
  • Don't refactor 2-3 branch logic. react-rules.md draws the line at 4+ — respect it.
  • Don't combine the refactor with unrelated cleanups. One concern per commit; reviewers can verify behavior parity.

References

  • Project rule: ~/.claude/rules/react-rules.md — "ts-pattern Usage" section
  • ts-pattern docs (live): Context7 /gvergnaud/ts-pattern
  • Validated examples in skills-desktop:
    • src/renderer/src/components/UpdateToast.tsx — 6-state union, four match blocks
    • src/renderer/src/components/skills/FileContent.tsx — 4-variant PreviewContent JSX dispatch
  • Counter-example (intentionally not refactored): src/renderer/src/components/skills/agentSelectionHelpers.ts:getOccupiedAgentReason — plain TS single-condition if-chain
Related skills
Installs
2
GitHub Stars
1
First Seen
2 days ago