Typing Libraries That Use Union and Intersection Types Extensively
Introduction
Many TypeScript libraries rely on union and intersection types to express flexible APIs, model variant data, and build composable abstractions. For intermediate developers, these features unlock powerful typing patterns but also introduce complexity: poor design can lead to fragile types, confusing inference, and maintenance headaches. This tutorial teaches how to design, implement, and maintain libraries that use unions and intersections extensively so your APIs remain ergonomic, safe, and performant.
You will learn how to model discriminated unions for exhaustive checks, use intersections to combine capabilities, guide inference with helper functions, avoid common pitfalls like type widening and excessive any, and apply practical patterns for real-world problems (middleware, plugin systems, command buses, parse/serialize workflows). Real code examples, step-by-step refactors, and troubleshooting tips are included so you can adopt these techniques immediately.
By the end of this article you'll be able to: design discriminated unions with robust exhaustiveness, compose interfaces with intersections cleanly, write inference-friendly factories and builders, and apply these patterns across common library designs—such as adapters, strategy implementations, mixins, and state machines—without losing type safety.
Background & Context
Union types (A | B) let values be one of several alternatives. Intersection types (A & B) let values satisfy multiple type constraints simultaneously. These constructs are foundational for expressing variant data and compositional behaviors in TypeScript, and they are used heavily in libraries with plugin/mocha-like middleware, command routing, or systems that combine capabilities at runtime.
Understanding how the compiler narrows unions, how intersections merge properties, and how generics and conditional types interact with unions/intersections is essential for building maintainable libraries. We will show patterns for robust typing and link to related design-pattern-focused guides (mixins, adapter and strategy) that often intersect with these techniques in real-world libraries.
Key Takeaways
- How to model discriminated unions for exhaustive type checks
- When to use intersections vs. interfaces or mapped types
- Techniques to preserve inference with helper factories and as-const
- Patterns for safe composition (mixins, plugins, adapters)
- Performance and maintainability trade-offs with complex type-level programming
- Troubleshooting common pitfalls like union widening and conflicting properties
Prerequisites & Setup
This tutorial assumes intermediate TypeScript knowledge: generics, mapped types, conditional types, and basic type inference. You'll need Node.js and a TypeScript environment (tsc >= 4.5 recommended). Create a project and install TypeScript with:
npm init -y npm install --save-dev typescript npx tsc --init
Set "strict": true in tsconfig.json to expose relevant compiler checks during examples. We'll use small TypeScript files; you can run npx tsc to check types or use VS Code for inline feedback.
Main Tutorial Sections
1) Modeling Discriminated Unions
Discriminated unions (tagged unions) are the safest way to model variants. Each variant has a literal "kind" property that the compiler can narrow on. Example:
type Success = { kind: 'success'; value: string };
type Error = { kind: 'error'; message: string };
type Result = Success | Error;
function handle(r: Result) {
if (r.kind === 'success') {
// r is Success
console.log(r.value);
} else {
// r is Error
console.error(r.message);
}
}For libraries, expose constructors to keep consumers inference-friendly. Use as const for literal narrowing when constructing inline objects. Discriminated unions are widely applicable—see patterns like command buses in our guide on Typing Command Pattern Implementations in TypeScript where commands are often discriminated by type.
2) Intersection Types for Capability Composition
Intersections merge multiple types into a single shape. Use them when you need objects to satisfy multiple capability interfaces. Example:
interface Loggable { log: (s: string) => void }
interface Storable { save: () => Promise<void> }
type Service = Loggable & Storable;
const service: Service = {
log: console.log,
save: async () => {/*...*/}
};Intersections are common for mixins and class composition; if you explore composing ES6 mixins, our practical guide on Typing Mixins with ES6 Classes in TypeScript — A Practical Guide shows patterns for blending capabilities while retaining type safety.
3) Guided Inference with Factory Functions
TypeScript often loses literal types when returning plain objects. Use factory helpers to preserve inference.
function make<T extends string>(kind: T) {
return <P extends object>(props: P) => ({ kind, ...props } as const);
}
const success = make('success')({ value: 'ok' });
// type is { readonly kind: "success"; readonly value: "ok" }Factories help create discriminated variants without repeated as const noise and ensure narrow types are preserved for downstream pattern matching. This approach is helpful in plugin-style libraries where clients register typed handlers.
4) Using Conditional Types with Unions
Conditional types distribute over unions, which is powerful but sometimes surprising. Consider extracting a payload type:
type ExtractPayload<T> = T extends { payload: infer P } ? P : never;
type A = { kind: 'a'; payload: { x: number } };
type B = { kind: 'b'; payload: { y: string } };
type P = ExtractPayload<A | B>; // { x: number } | { y: string }This distribution allows mapping unions to corresponding payload unions. When designing library-level utilities, intentionally leverage or avoid distribution by wrapping types in tuples when you want to suppress distribution.
5) Intersection Conflicts & Property Merging
When intersections share property names with incompatible types, TypeScript produces never for that property. Example:
type A = { x: number }
type B = { x: string }
type C = A & B // { x: never }To avoid this, prefer composition via unique property names or union variants. If you need a single API surface with overlapping names, consider using overloads or discriminated unions rather than intersections. Patterns such as proxying or adapters often force these decisions—our guide on Typing Proxy Pattern Implementations in TypeScript discusses safe interception strategies that avoid incompatible merges.
6) Narrowing Complex Unions in Switches
For exhaustive checks, use switch statements over a discriminant. Combine with a never-assertion helper for compile-time exhaustiveness:
function assertNever(x: never): never { throw new Error('Unexpected: ' + x); }
function handle(u: Result) {
switch (u.kind) {
case 'success': return u.value;
case 'error': return u.message;
default: return assertNever(u);
}
}This pattern forces future variant additions to be handled. For higher-level patterns like the State or Chain of Responsibility, this exhaustive approach prevents subtle runtime errors—see Typing State Pattern Implementations in TypeScript for examples where exhaustive checks are critical.
7) Tagged Unions Across API Boundaries
When your library accepts user-supplied handlers (plugins), define small discriminated union contracts to keep implementation predictable. Example plugin registration:
type Plugin =
| { type: 'logger'; setup: () => Loggable }
| { type: 'storage'; setup: () => Storable };
function register(p: Plugin) { /*...*/ }Document each plugin shape clearly and provide factories to help consumers create typed plugins. Libraries that manage modular features often benefit from pattern documentation similar to our Typing Module Pattern Implementations in TypeScript — Practical Guide.
8) Advanced Mapping: Discriminated Unions to APIs
Map union variants to handler maps using mapped and conditional types. Example: automatic handler type extraction:
type Handlers<U extends { type: string }> = {
[K in U as K['type']]: (u: Extract<U, { type: K['type'] }>) => void
}
// Usage with union UThis pattern transforms a union into an object keyed by discriminants, allowing O(1) dispatch at runtime while retaining precise typing. This approach appears in typed routing or visitor implementations—see the visitor guide Typing Visitor Pattern Implementations in TypeScript for similar transforms.
9) Combining Unions & Intersections for Plugin Systems
Plugin systems often require records of capabilities: a plugin may implement several optional interfaces. Use intersections of partial capability interfaces plus a discriminant for plugin kind:
type PluginBase = { name: string };
type Capabilities = Partial<{ log: Loggable; store: Storable }>;
type AnyPlugin = PluginBase & Capabilities & { kind: 'plugin' };At runtime, check for capabilities (e.g., if ('log' in p)), and at compile time, use helper types to extract available capabilities. This is related to adapter and strategy patterns; see our guide on Typing Adapter Pattern Implementations in TypeScript — A Practical Guide for techniques to adapt runtime shapes into typed interfaces.
10) Interop with Structural OOP Patterns
Many object-oriented patterns (strategy, command, mediator) can be typed using unions and intersections. For example, the Strategy pattern may expose different option shapes as unions, while a composite strategy composes via intersections. For full pattern-oriented examples, consult Typing Strategy Pattern Implementations in TypeScript and Typing Mediator Pattern Implementations in TypeScript to see how these patterns map to type-level constructs.
Advanced Techniques
Once comfortable with basics, adopt advanced strategies: use distributive conditional types intentionally to derive unions of payloads; leverage variadic tuple types (TS 4.x) to model curried function pipelines; use branded types to avoid accidental collisions when intersections share property names; and create inference helpers with overloads to improve DX.
For performance, keep type-level computations shallow—complex nested conditional types slow down editor responsiveness. Use type Alias = { /* simplified shape */ } to hide heavy computations from the editor and re-export a lighter interface. Memoize type-level operations by factoring them into named intermediate types to improve compiler performance.
Best Practices & Common Pitfalls
Do:
- Prefer discriminated unions where possible; they’re easy to narrow.
- Use factory functions to preserve literal inference.
- Keep intersections simple and avoid merging incompatible properties.
- Provide runtime guards for untrusted input.
Don't:
- Avoid overly complex conditional types that hurt editor perf.
- Don’t rely on structural equality for distinguishing variants without a discriminant.
- Don’t export deeply computed types that hurt downstream tooling.
Common pitfalls:
- Literal widening: e.g., "foo" becomes string unless
as constis used. Use factories oras constwhere appropriate. - Property conflicts in intersections leading to never. Address this by designing unique keys or using unions with discriminants.
When debugging, add small utility types to inspect intermediate forms (e.g., assign to a named type and hover in your editor). For runtime mismatch issues, add explicit type guards. Libraries that bridge runtime and compile-time contracts (like interpreters or flyweights) often include both guard code and type helpers—see examples in Typing Interpreter Pattern Implementations in TypeScript and Typing Flyweight Pattern Implementations in TypeScript.
Real-World Applications
- Command buses: discriminated command unions simplify routing and parameter extraction—see Typing Command Pattern Implementations in TypeScript.
- Plugin frameworks: use intersections for optional capabilities and unions for plugin kinds—see the module pattern guide Typing Module Pattern Implementations in TypeScript — Practical Guide.
- Middleware pipelines: represent middleware outcome types with unions and map them to handlers.
- State machines: use discriminated unions for states and transitions, with exhaustive checks to ensure safety—see Typing State Pattern Implementations in TypeScript.
Also consider integrations with callback-heavy or event-driven systems: libraries that use Node-style callbacks or event emitters often need robust union/intersection typing to model handler signatures precisely—see our related pieces on Typing Libraries That Use Callbacks Heavily (Node.js style) and Typing Libraries That Use Event Emitters Heavily for best practices when combining runtime patterns with static types.
Conclusion & Next Steps
Union and intersection types are powerful tools for library authors. Start small—model a discriminated union for a single feature and add factories for inference. Gradually introduce intersections for composition with careful attention to property conflicts and compiler performance. Explore the linked pattern guides to apply these techniques in concrete architectural scenarios, then iterate on type ergonomics based on consumer feedback and editor experience.
Next steps: practice by converting a small plugin or command-based subsystem to use discriminated unions and factories, and measure both DX and runtime safety.
Enhanced FAQ
Q1: When should I prefer unions over intersections? A1: Use unions when a value can be one of several distinct alternatives (variant data), and intersections when a value should simultaneously satisfy multiple capabilities. For example, a shape that is either Circle or Rectangle is a union; a service that is both Loggable and Storable is an intersection.
Q2: How do I preserve literal types when returning objects from functions?
A2: Use as const on the literal object or create generic factory helpers that return as const results. Example factory:
function tag<T extends string>(t: T) {
return <P extends object>(p: P) => ({ type: t, ...p } as const);
}This preserves the literal type and object properties as readonly literals.
Q3: Why do intersections sometimes produce never for properties? A3: If two intersected types have the same property name but incompatible types (e.g., string vs number), TypeScript computes the intersection of property types, which is an empty set (never). Avoid by designing distinct properties or using unions instead.
Q4: How do conditional types distribute over unions, and why does it matter?
A4: In constructs like T extends X ? A : B, if T is a union, the conditional is applied to each member and the results are unioned. This is useful to map union members to other forms (like extracting payloads), but sometimes you need to suppress distribution by wrapping T in a single-element tuple: [T] extends [X] ? A : B.
Q5: How can I ensure exhaustiveness when switching over a discriminated union?
A5: Use a default case that calls a helper asserting never. Example:
function assertNever(x: never): never { throw new Error('Unhandled: ' + x); }If a new variant is added, the compiler will flag the assertNever call as invalid unless you handle the new case.
Q6: Are there performance concerns with complex type-level logic? A6: Yes. Complex nested conditional types and large unions can slow down the TypeScript compiler and editor features. Keep heavy computations isolated in named types, and avoid exporting enormous computed types to library consumers. Factor type logic into smaller, memoizable pieces.
Q7: How do unions and intersections relate to common design patterns? A7: Many patterns map naturally: State and Command benefit from discriminated unions; Strategy and Adapter often use intersections for composed capabilities or unions for alternative strategies. Our guides on Typing Adapter Pattern Implementations in TypeScript — A Practical Guide and Typing Strategy Pattern Implementations in TypeScript provide concrete examples.
Q8: How should I design plugin APIs that use unions and intersections? A8: Expose a small discriminated union for plugin kinds and use intersections for optional capability sets. Provide factory functions to simplify consumer code and runtime guards to validate untrusted plugin shapes. For modules and modular systems, reference Typing Module Pattern Implementations in TypeScript — Practical Guide for structure.
Q9: Can I mix class-based mixins with union/intersection typing? A9: Yes. Mixins often produce intersection-like results (the class must satisfy multiple instance types). See Typing Mixins with ES6 Classes in TypeScript — A Practical Guide for recommended patterns that keep typings clear and composable.
Q10: What runtime checks should I add when using these types with external input? A10: For external input, use runtime type guards and validation (io-ts, zod, hand-rolled guards). Even though TypeScript provides compile-time guarantees for typed code, external JSON or plugin inputs require runtime validation to ensure they match your discriminated unions or intersection shapes. For interpreters and flyweights where runtime representation is critical, check out Typing Interpreter Pattern Implementations in TypeScript and Typing Flyweight Pattern Implementations in TypeScript for approaches that combine runtime guards with static types.
