install
source · Clone the upstream repo
git clone https://github.com/Intense-Visions/harness-engineering
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/codex/ts-discriminated-unions" ~/.claude/skills/intense-visions-harness-engineering-ts-discriminated-unions-b8dfa4 && rm -rf "$T"
manifest:
agents/skills/codex/ts-discriminated-unions/SKILL.mdsource content
TypeScript Discriminated Unions
Model mutually exclusive states with discriminated unions and exhaustive narrowing
When to Use
- Representing states that cannot coexist (loading/success/error, open/closed)
- Replacing boolean flags or optional fields that create invalid state combinations
- Ensuring switch statements handle all possible cases
- Modeling domain events, API responses, or form states
Instructions
- Define a discriminated union with a shared literal field (discriminant):
type Result<T> = | { status: 'success'; data: T } | { status: 'error'; error: Error } | { status: 'loading' };
- Narrow with
orswitch
statements — TypeScript narrows the type automatically:if
function handleResult<T>(result: Result<T>) { switch (result.status) { case 'success': console.log(result.data); // TypeScript knows data exists here break; case 'error': console.log(result.error); // TypeScript knows error exists here break; case 'loading': console.log('Loading...'); break; } }
- Exhaustive checking with
— catch unhandled cases at compile time:never
function assertNever(x: never): never { throw new Error(`Unexpected value: ${x}`); } function handleResult<T>(result: Result<T>): string { switch (result.status) { case 'success': return 'OK'; case 'error': return 'FAIL'; case 'loading': return 'WAIT'; default: return assertNever(result); // If a new status is added, this line errors at compile time } }
- Replace boolean flags with discriminated unions:
// Bad: invalid states are possible (isLoading + error both true) interface State { isLoading: boolean; data?: User; error?: Error; } // Good: each state is explicitly defined type State = | { kind: 'idle' } | { kind: 'loading' } | { kind: 'success'; data: User } | { kind: 'error'; error: Error };
- Model domain events:
type OrderEvent = | { type: 'ORDER_PLACED'; orderId: string; items: Item[] } | { type: 'PAYMENT_RECEIVED'; orderId: string; amount: number } | { type: 'ORDER_SHIPPED'; orderId: string; trackingNumber: string } | { type: 'ORDER_CANCELLED'; orderId: string; reason: string }; function processEvent(event: OrderEvent): void { switch (event.type) { case 'ORDER_PLACED': // event.items is available break; case 'ORDER_SHIPPED': // event.trackingNumber is available break; } }
- Combine with generics:
type ApiResponse<T> = | { ok: true; data: T; status: number } | { ok: false; error: string; status: number }; async function fetchUser(): Promise<ApiResponse<User>> { // ... }
- Discriminate on multiple properties when one is not enough:
type Shape = | { kind: 'circle'; radius: number } | { kind: 'rectangle'; width: number; height: number } | { kind: 'triangle'; base: number; height: number };
- Use
operator for narrowing when there is no explicit discriminant:in
type Fish = { swim: () => void }; type Bird = { fly: () => void }; function move(animal: Fish | Bird) { if ('swim' in animal) { animal.swim(); // Narrowed to Fish } }
Details
A discriminated union (also called a tagged union) is a union of types that share a common property with literal type values. TypeScript uses this property as a discriminant to narrow the type in conditional branches.
The discriminant property must be:
- Present on every member of the union
- A literal type (string literal, number literal, boolean literal)
- Unique per member (or at least narrow enough to distinguish)
Exhaustive checking patterns:
withswitch
— throws at runtime if an unhandled case is reacheddefault: assertNever(x)- Assigning to
variable:never
— compile-time only, no runtime overheadconst _exhaustive: never = x - TypeScript's
and--noUncheckedIndexedAccess
enhance exhaustiveness checkingstrictNullChecks
Performance: Discriminated unions have zero runtime overhead beyond the discriminant property. The narrowing happens entirely at compile time.
Common naming conventions for discriminants:
— for geometric shapes, node types, abstract syntax treeskind
— for events, actions, messagestype
— for state machines, API responsesstatus
— for algebraic data typestag
Trade-offs:
- Discriminated unions make invalid states unrepresentable — but require more type definitions upfront
- Adding a new variant requires updating all switch statements — the exhaustive check catches this at compile time
- Deep nesting of discriminated unions can make type inference slow
- String literal discriminants are not refactoring-friendly — renaming a string literal requires finding all usage sites
Source
https://typescriptlang.org/docs/handbook/2/narrowing.html
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.