← Back to Blog
TypeScript2026-05-12·86 min read

Why I built ts-match: TypeScript branching in the era of coding agents

By Diego Garcia Brisa

Discriminant-First Branching: Taming TypeScript Unions in Complex Systems

Current Situation Analysis

TypeScript's discriminated unions remain one of the most effective mechanisms for modeling variant-heavy domain logic. They provide compile-time safety for state machines, event streams, API responses, and orchestration pipelines. However, the moment a union exceeds five or six variants, the surrounding control flow begins to degrade.

The industry pain point is not type safety. It is cognitive load. When developers rely on traditional switch statements or nested if/else chains, the visual structure of the code shifts from expressing intent to managing control flow. Each additional branch increases the mental overhead required to trace data paths, verify exhaustiveness, and maintain consistency across teams. This problem compounds in modern architectures where event-driven systems, async orchestration, and AI-assisted code generation are standard.

This issue is frequently overlooked because early-stage implementations work flawlessly. A three-case union handled by a switch statement is readable, type-safe, and performant. The degradation is incremental. By the time a union reaches twelve variants, the branching logic dominates the file. Handlers become scattered, shared behavior is duplicated, and async boundaries force developers to rewrite synchronous patterns into promise chains. The type system still works, but the code shape becomes hostile to review and refactoring.

Evidence from software engineering research consistently shows that branching depth and control flow complexity are primary drivers of defect rates. When AI coding agents generate branching logic, they often produce structurally inconsistent patterns. One file might use a switch, another might use a lookup map, and a third might inline conditional logic. This inconsistency breaks code review efficiency and makes long-term maintenance unpredictable. The industry lacks a standardized, lightweight approach to discriminant dispatch that preserves type narrowing while enforcing a consistent architectural shape across human and AI-generated code.

WOW Moment: Key Findings

The shift from traditional control flow to discriminant-first pattern matching reveals measurable improvements in code maintainability, async parity, and AI-agent compatibility. The following comparison isolates the structural trade-offs between three common approaches:

Approach Cognitive Load Async Parity Type Narrowing Precision AI-Agent Consistency Bundle Size
Traditional Switch/If-Else High (scales with branch count) Poor (requires manual promise unwrapping) Excellent (native TS) Low (inconsistent patterns) 0 KB
Structural Pattern Matching Medium-High (complex patterns increase parsing time) Good (built-in async support) Excellent (deep destructuring) Medium (verbose syntax varies) ~12 KB
Discriminant-First Matching Low (flat, declarative mapping) Excellent (native promise-aware API) Excellent (key-based narrowing) High (predictable shape) ~3 KB

This finding matters because it decouples branching logic from control flow ceremony. When the dispatch mechanism is explicitly tied to a known discriminant key, the code becomes a direct mapping from variants to behavior. Async values no longer force architectural changes. Boundary validation can be lightweight without sacrificing safety. Most importantly, AI agents can be prompted to generate consistent branching structures, reducing review friction and preventing pattern drift across large codebases.

Core Solution

The discriminant-first approach optimizes for a specific reality: in most application code, you already know the key that determines branching. You do not need deep structural pattern matching for every case. You need a predictable, type-safe mapping that survives async boundaries, grows gracefully, and remains readable under AI generation.

Step 1: Define the Variant Union

Start with a realistic event stream. Instead of generic examples, consider a cloud deployment orchestrator that tracks infrastructure provisioning, configuration drift, and rollout phases.

type DeploymentEvent =
  | { kind: "provision-start"; instanceId: string; region: string; timestamp: number }
  | { kind: "provision-complete"; instanceId: string; status: "healthy" | "degraded"; timestamp: number }
  | { kind: "config-sync"; instanceId: string; targetVersion: string; checksum: string; timestamp: number }
  | { kind: "rollout-phase"; phase: "canary" | "blue-green" | "full"; progress: number; timestamp: number }
  | { kind: "health-check"; instanceId: string; latency: number; errorRate: number; timestamp: number }
  | { kind: "rollback-trigger"; reason: "latency-spike" | "error-threshold" | "manual"; instanceId: string; timestamp: number }
  | { kind: "deployment-finalized"; status: "success" | "failed"; durationMs: number; timestamp: number };

The discriminant is kind. Every variant shares this key, but carries distinct payloads. TypeScript will narrow correctly, but the branching strategy determines long-term maintainability.

Step 2: Implement Discriminant-First Dispatch

Replace control flow with a declarative mapping. The matchBy API accepts the union value and the discriminant key, returning a chainable builder that enforces type narrowing per case.

import { matchBy } from "@diegogbrisa/ts-match";

function processDeployment(state: DeploymentState, event: DeploymentEvent): DeploymentState {
  return matchBy(event, "kind")
    .with("provision-start", (e) => trackInstance(state, e.instanceId, e.region))
    .with("provision-complete", (e) => updateInstanceHealth(state, e.instanceId, e.status))
    .with("config-sync", (e) => applyConfiguration(state, e.instanceId, e.targetVersion, e.checksum))
    .with("rollout-phase", (e) => advanceRollout(state, e.phase, e.progress))
    .with("health-check", (e) => evaluateMetrics(state, e.instanceId, e.latency, e.errorRate))
    .with("rollback-trigger", (e) => initiateRollback(state, e.reason, e.instanceId))
    .with("deployment-finalized", (e) => finalizeDeployment(state, e.status, e.durationMs))
    .otherwise(() => state);
}

Architecture Rationale:

  • Key-First Dispatch: By explicitly declaring "kind", the compiler knows exactly which property to narrow. This eliminates guesswork and prevents accidental matches on unrelated fields.
  • Handler Isolation: Each callback receives the fully narrowed event. No manual type guards, no as casts, no scattered conditional logic.
  • Default Fallback: .otherwise() acts as a safety net for unknown variants or future-proofing. In strict mode, you can replace it with .exhaustive() to force compile-time coverage checks.

Step 3: Unify Synchronous and Asynchronous Branching

Real-world systems rarely stay synchronous. Data sources shift from in-memory state to API calls, message queues, or streaming endpoints. The branching style should not change when the source becomes promise-backed.

import { match } from "@diegogbrisa/ts-match";

async function resolveNextAction(input: DeploymentInput): Promise<DeploymentAction> {
  const result = await match.promise(fetchDeploymentStatus(input.runId))
    .with({ kind: "provision-complete", status: "healthy" }, () => ({ action: "proceed-to-config" }))
    .with({ kind: "provision-complete", status: "degraded" }, () => ({ action: "retry-provision" }))
    .with({ kind: "rollback-trigger" }, () => ({ action: "halt-and-notify" }))
    .with({ kind: "deployment-finalized", status: "failed" }, () => ({ action: "generate-report" }))
    .otherwise(() => ({ action: "wait-for-next-event" }));

  return result;
}

Why This Matters:

  • Style Parity: The same .with() / .otherwise() structure works for sync and async values. Teams do not need to maintain two branching paradigms.
  • Promise Resolution: The library handles await internally, preventing nested .then() chains or manual try/catch wrappers around pattern matching.
  • Type Safety: The narrowed payload remains available inside each handler, even when resolved from a promise.

Step 4: Lightweight Boundary Assertions

Data crossing trust boundaries (IPC, local storage, external APIs, JSON deserialization) requires validation. Full schema validators like Zod or Valibot are appropriate for complex parsing, transformation, and error reporting. However, sometimes you only need to fail fast on an obviously wrong shape before handing control to the type system.

import { assertMatching, P } from "@diegogbrisa/ts-match";

function ingestRawEvent(raw: unknown): DeploymentEvent {
  const parsed = JSON.parse(raw as string);

  assertMatching(parsed, {
    kind: P.string,
    timestamp: P.number,
  });

  return parsed as DeploymentEvent;
}

Architectural Decision:

  • assertMatching is not a replacement for comprehensive validation. It is a lightweight guard that throws immediately if the discriminant or critical fields are missing.
  • This keeps the runtime overhead minimal while preventing silent failures in downstream handlers.
  • For production systems requiring detailed error paths, combine this with a dedicated validation layer before the assertion.

Pitfall Guide

1. Treating Pattern Matching as a Validation Layer

Explanation: Developers often attempt to validate complex business rules inside .with() clauses. Pattern matching is designed for dispatch, not data sanitization. Fix: Validate data at the boundary using a dedicated schema library. Pass only trusted, narrowed values into the matcher.

2. Ignoring Exhaustiveness in Growing Unions

Explanation: As unions expand, new variants are added without updating all matchers. .otherwise() masks missing cases, leading to silent fallbacks. Fix: Use .exhaustive() in strict TypeScript projects. Enable noImplicitReturns and strictNullChecks to catch uncovered branches at compile time.

3. Nesting Match Calls Unnecessarily

Explanation: Developers sometimes chain matchers inside handlers to handle sub-variants. This creates deep call stacks and obscures the primary dispatch path. Fix: Flatten the union structure. If sub-variants exist, model them as separate discriminants or use composition rather than nested matching.

4. Mixing Synchronous and Asynchronous Branching Styles

Explanation: Some files use matchBy for sync events and match.promise for async ones, causing inconsistent code shape across the codebase. Fix: Standardize on a single branching convention per module. If async is dominant, wrap sync values in Promise.resolve() to maintain uniform syntax.

5. Over-Engineering Simple State Transitions

Explanation: Applying pattern matching to two or three trivial cases adds abstraction overhead without readability gains. Fix: Reserve discriminant-first matching for unions with five or more variants, or when async/boundary consistency is required. Use if/else or lookup maps for simpler cases.

6. Bypassing TypeScript's Native Narrowing

Explanation: Developers sometimes cast results or use any inside handlers to avoid type errors, defeating the purpose of the matcher. Fix: Trust the narrowing. If TypeScript complains, the union definition is likely incomplete or the discriminant key is misaligned. Fix the type definition, not the handler.

7. Ignoring ESM Module Constraints

Explanation: The library is distributed as ESM-only. Attempting to import it in CommonJS environments without proper configuration causes runtime failures. Fix: Configure tsconfig.json with "module": "NodeNext" or "ESNext". Ensure package.json includes "type": "module" or use dynamic imports in legacy setups.

Production Bundle

Action Checklist

  • Audit existing discriminated unions: Identify files with switch/if-else branching exceeding five cases.
  • Standardize discriminant keys: Ensure all variants in a union share a consistent property name (e.g., kind, type, event).
  • Replace control flow with matchers: Migrate high-cognitive-load branching to matchBy or match.promise.
  • Enforce exhaustiveness: Enable .exhaustive() in strict mode and configure CI to fail on uncovered variants.
  • Separate validation from dispatch: Move complex parsing to Zod/Valibot; use assertMatching only for lightweight boundary guards.
  • Document branching conventions: Add a SKILL.md or internal guide for AI agents to generate consistent matcher syntax.
  • Monitor bundle impact: Verify ESM tree-shaking removes unused matcher utilities in production builds.

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Union with 2-3 variants if/else or lookup map Lower abstraction overhead, faster execution Minimal
Union with 5+ variants, sync flow matchBy(discriminant) Improves scanability, enforces narrowing Low (dev time)
Async event streams or promise-backed data match.promise() Maintains consistent branching shape across sync/async boundaries Low
Untrusted external data (JSON, IPC) assertMatching() + schema validator Fails fast on shape mismatch without full validation overhead Low
AI-generated codebases Standardized matcher pattern + SKILL.md Prevents pattern drift, simplifies human review Medium (initial setup)
Performance-critical hot paths Native switch or lookup Matcher introduces minimal runtime overhead; avoid in tight loops High (if misapplied)

Configuration Template

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
// package.json
{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src --ext .ts"
  },
  "dependencies": {
    "@diegogbrisa/ts-match": "^1.0.0"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "eslint": "^8.56.0"
  }
}
// src/matchers/deployment.matcher.ts
import { matchBy, match, assertMatching, P } from "@diegogbrisa/ts-match";
import type { DeploymentEvent, DeploymentState, DeploymentAction } from "../types";

export const handleDeploymentEvent = (state: DeploymentState, event: DeploymentEvent): DeploymentState =>
  matchBy(event, "kind")
    .with("provision-start", (e) => state.trackInstance(e.instanceId, e.region))
    .with("provision-complete", (e) => state.updateHealth(e.instanceId, e.status))
    .with("config-sync", (e) => state.applyConfig(e.instanceId, e.targetVersion, e.checksum))
    .with("rollout-phase", (e) => state.advancePhase(e.phase, e.progress))
    .with("health-check", (e) => state.evaluateMetrics(e.instanceId, e.latency, e.errorRate))
    .with("rollback-trigger", (e) => state.initiateRollback(e.reason, e.instanceId))
    .with("deployment-finalized", (e) => state.finalize(e.status, e.durationMs))
    .otherwise(() => state);

export const resolveDeploymentAction = async (input: { runId: string }): Promise<DeploymentAction> =>
  match.promise(fetchStatus(input.runId))
    .with({ kind: "provision-complete", status: "healthy" }, () => ({ action: "proceed" }))
    .with({ kind: "provision-complete", status: "degraded" }, () => ({ action: "retry" }))
    .with({ kind: "rollback-trigger" }, () => ({ action: "halt" }))
    .with({ kind: "deployment-finalized", status: "failed" }, () => ({ action: "report" }))
    .otherwise(() => ({ action: "wait" }));

export const ingestRawEvent = (raw: unknown): DeploymentEvent => {
  const parsed = JSON.parse(raw as string);
  assertMatching(parsed, { kind: P.string, timestamp: P.number });
  return parsed as DeploymentEvent;
};

Quick Start Guide

  1. Install the package: Run npm install @diegogbrisa/ts-match or pnpm add @diegogbrisa/ts-match. Ensure your project uses ESM modules.
  2. Define your union: Create a discriminated union with a consistent key (e.g., kind, type, event). Verify TypeScript narrows correctly using tsc --noEmit.
  3. Replace branching logic: Identify a switch or if/else chain handling five or more variants. Replace it with matchBy(value, "key") and map each case to a handler.
  4. Handle async boundaries: If the source is promise-backed, switch to match.promise(asyncValue) and maintain the same .with() / .otherwise() structure.
  5. Enforce coverage: Add .exhaustive() to critical matchers. Configure CI to fail on uncovered variants. Document the pattern for AI agents and team reviewers.