TypeScript Intersection Types Done Right: When They Compose Cleanly and When They Silently Lie
Most TypeScript composition failures stem from misunderstanding intersection types. Learn when they compose cleanly, when they produce never, and how to avoid the silent lies that break production code.
TypeScript Intersection Types Done Right: When They Compose Cleanly and When They Silently Lie
Most TypeScript composition failures stem from developers treating intersection types as simple object merging. The pattern teams overlook is that intersections follow set-theoretic rules, not object-spread semantics. When developers write A & B, they expect "all properties from A plus all properties from B." What they get is "values that satisfy both A and B simultaneously." This distinction is critical because it determines when composition produces useful types and when it silently creates never, breaking type safety without warning.
The failure mode here is subtle but expensive. A developer combines two types expecting a richer interface. The compiler accepts it. Tests pass. Then production breaks because the intersection resolved to never, accepting literally any value. The fix requires understanding when intersection types compose cleanly versus when they conflict, and knowing which composition tool to reach for in each scenario.
%% alt: Problem flow showing how naive intersection creates never type
flowchart LR
A("Developer writes A & B")
B("Expects merged properties")
C("Compiler stays silent")
D("Runtime accepts any value")
A --> B --> C --> D
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
The solution is recognizing that intersection types work for compatible structures. When types share conflicting property signatures, the intersection collapses to never. When they share compatible or non-overlapping properties, they compose cleanly into a richer type that enforces both contracts.
%% alt: Solution flow showing safe intersection composition
flowchart LR
A("Developer writes A & B")
B("Checks property compatibility")
C("Uses compatible types")
D("Gets enforced combined contract")
A --> B --> C --> D
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- Intersection types follow set theory:
A & Bmeans "values satisfying both A and B," not "merge all properties." - Conflicting property signatures (same name, incompatible types) collapse intersections to
never, silently breaking type safety. - Compatible structures (non-overlapping properties or matching signatures) compose cleanly into enforced combined contracts.
- Intersections excel at mixing capabilities; unions excel at "one of several shapes" scenarios.
- The
nevertrap appears when runtime shapes cannot simultaneously satisfy both types, making every value assignable.
Understanding Intersection Types: The Basics
Intersection types create a type that must satisfy all constituent types simultaneously. The syntax A & B produces a type where every value must be both an A and a B at the same time. This matters because developers often mistake intersections for object spread or merging, leading to surprising results when types conflict.
type WithId = { id: string };
type WithTimestamp = { createdAt: Date };
type Entity = WithId & WithTimestamp;
const user: Entity = {
id: "user-123",
createdAt: new Date(),
}; // ✓ Valid: satisfies both typesThe intersection succeeds here because the properties do not conflict. An object can have both id: string and createdAt: Date simultaneously. The compiler enforces both contracts, requiring all properties from both types.
The trouble begins when property signatures conflict. If two types define the same property name with incompatible types, the intersection becomes never because no runtime value can simultaneously satisfy both constraints.
type ApiResponse = { status: number };
type ErrorResponse = { status: string };
type Conflict = ApiResponse & ErrorResponse;
// Conflict = { status: never }
// The 'status' property must be both number AND string
const response: Conflict = {
status: 200, // ❌ Type 'number' is not assignable to type 'never'
};The implication here is that TypeScript resolved status to never because no value is both a number and a string. The type system correctly identified an impossible constraint, but the error message appears at assignment time, not at the intersection declaration. Teams often miss this until runtime behavior breaks.
%% alt: Intersection type resolution showing clean merge vs never collapse
flowchart TD
A("Intersection A & B")
B("Check property names")
C("Non-overlapping or compatible?")
D("Merge properties")
E("Resolve conflict to never")
F("Clean combined type")
G("Broken type safety")
A --> B --> C
C -->|"Yes"| D --> F
C -->|"No"| E --> G
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#ef4444,fill:#450a0a,color:#fca5a5
Understanding this mechanism prevents the most common intersection pitfall: assuming compatibility when types actually conflict. The next section shows when intersections compose cleanly and deliver the intended behavior.
Clean Composition: When Intersections Work Perfectly
Intersections compose cleanly when types contribute non-overlapping properties or when overlapping properties share identical signatures. This pattern appears frequently in capability mixing, where each type represents a distinct concern that enriches the final interface.
type Auditable = {
createdBy: string;
createdAt: Date;
updatedBy: string;
updatedAt: Date;
};
type Deletable = {
deletedBy: string | null;
deletedAt: Date | null;
};
type Versioned = {
version: number;
versionHistory: string[];
};
type FullEntity = Auditable & Deletable & Versioned;
const document: FullEntity = {
createdBy: "alice",
createdAt: new Date("2026-01-01"),
updatedBy: "bob",
updatedAt: new Date("2026-08-12"),
deletedBy: null,
deletedAt: null,
version: 3,
versionHistory: ["v1", "v2", "v3"],
}; // ✓ All properties requiredThe intersection succeeds because each type contributes unique properties. No conflicts exist, so the compiler enforces all nine properties. This matters because developers can compose rich domain models from smaller, focused types without introducing fragility.
Compatible overlapping properties also compose cleanly. When two types share a property name but the signatures match exactly, the intersection preserves the single property definition.
type WithId = { id: string; name: string };
type WithMetadata = { id: string; tags: string[] };
type Combined = WithId & WithMetadata;
// Combined = { id: string; name: string; tags: string[] }
const item: Combined = {
id: "item-456",
name: "Widget",
tags: ["new", "featured"],
}; // ✓ Single 'id' property, type stringThe id property appears in both source types with identical signatures (string), so the intersection keeps one copy. The compiler does not duplicate properties; it unifies them when compatible. This distinction is critical for understanding when composition succeeds versus when it produces never.
The practical benefit is that teams can build type hierarchies from reusable fragments. Audit trails, timestamps, versioning, and soft-delete patterns compose into complete entity types without manual duplication. The type system enforces every capability, catching missing properties at compile time.
In other words, intersections work perfectly when developers compose compatible, non-conflicting structures. The failure mode emerges when property signatures clash, turning a well-intentioned composition into a silent type-safety trap.
Silent Lies: The never Type Trap
The never trap occurs when intersections resolve to never due to conflicting property signatures, yet the compiler allows assignments that should fail. This matters because never represents the empty type—no value inhabits it—so TypeScript's assignability rules invert: everything becomes assignable to never. The result is silent type-safety loss.
type NumericId = { id: number };
type StringId = { id: string };
type Broken = NumericId & StringId;
// Broken = { id: never }
const entity: Broken = {
id: "any-value-works", // ✓ No error! String literal is assignable to never
};
const alsoWorks: Broken = {
id: 12345, // ✓ No error! Number is assignable to never
};
const evenThis: Broken = {
id: null as any, // ✓ No error! Any value is assignable to never
};The compiler accepts all three assignments because never is the bottom type. Every type is a supertype of never, so every value is technically assignable to it. This inverts the expected behavior: instead of rejecting invalid shapes, the type system becomes permissive, accepting anything.
The failure mode here is expensive because it breaks at runtime, not compile time. Developers see green checkmarks from tsc, ship the code, and discover the bug only when production data flows through the broken type. The fix requires detecting the never resolution before it escapes into the codebase.
type SafeCheck<T> = [T] extends [never] ? "ERROR: Type resolved to never" : T;
type Test = SafeCheck<NumericId & StringId>;
// Test = "ERROR: Type resolved to never"The conditional type wraps the intersection in a tuple to prevent distributive behavior, then checks if the result extends never. When it does, the type resolves to an error message instead of silently breaking. Teams can use this pattern in type definitions to catch never collapses early.
The implication here is that intersections require validation. Developers cannot assume composition will succeed; they must verify that property signatures align or that properties do not overlap. When conflicts exist, the correct tool is usually a union type or a redesigned interface hierarchy, not an intersection.
This distinction is critical: intersections enforce "both A and B," unions enforce "either A or B." Choosing the wrong operator produces either never (impossible constraint) or overly permissive types (missing enforcement). The next section shows when each tool applies.
Intersection vs Union: Choosing the Right Tool
Intersections and unions solve opposite problems, yet teams frequently swap them, producing broken type safety. Intersections enforce "this value must satisfy all these types simultaneously," while unions enforce "this value matches exactly one of these shapes." Understanding when each applies prevents the never trap and overly permissive types.
%% alt: Comparison of intersection and union type resolution
flowchart LR
subgraph Intersection["Intersection A & B"]
I1("Start with value")
I2("Must satisfy A")
I3("Must also satisfy B")
I4("Enforces both contracts")
end
subgraph Union["Union A | B"]
U1("Start with value")
U2("Satisfies A or B")
U3("Enforces one contract")
end
I1 --> I2 --> I3 --> I4
U1 --> U2 --> U3
style I4 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style U3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Use intersections when combining capabilities. Each type represents a distinct set of properties or methods that the final value must support. The intersection creates a richer type enforcing all capabilities.
type Clickable = {
onClick: () => void;
};
type Draggable = {
onDragStart: () => void;
onDragEnd: () => void;
};
type InteractiveElement = Clickable & Draggable;
const button: InteractiveElement = {
onClick: () => console.log("clicked"),
onDragStart: () => console.log("drag start"),
onDragEnd: () => console.log("drag end"),
}; // ✓ All three methods requiredThe intersection succeeds because the capabilities do not conflict. A single element can be both clickable and draggable. The type system enforces all three methods, catching missing implementations at compile time.
Use unions when a value matches one of several shapes. Each type represents a distinct variant, and the value must conform to exactly one. Discriminated unions with a shared tag property enable exhaustive type narrowing.
type SuccessResponse = {
status: "success";
data: unknown;
};
type ErrorResponse = {
status: "error";
message: string;
};
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse) {
if (response.status === "success") {
console.log(response.data); // ✓ TypeScript knows this is SuccessResponse
} else {
console.log(response.message); // ✓ TypeScript knows this is ErrorResponse
}
}The union works because the status tag disambiguates the two shapes. TypeScript narrows the type inside each branch, providing full intellisense and type safety. Trying to use an intersection here would fail: SuccessResponse & ErrorResponse requires status to be both "success" and "error" simultaneously, producing never.
The practical guideline is straightforward. If adding capabilities to a single value, use intersections. If representing alternatives where a value is one of several distinct shapes, use unions. Mixing them up produces either impossible types or loss of type narrowing.
This matters because the right choice determines whether the type system helps or hinders. Intersections enforce completeness; unions enforce exhaustiveness. Choosing incorrectly breaks both, leaving developers with runtime bugs that static analysis should have caught. The next section shows how these principles apply to real-world React and API composition patterns.
Real-World Patterns: Component Props and API Composition
Component props and API response types are where intersection misuse most commonly breaks production code. The pattern teams overlook is that props often need capability mixing (intersections), while API responses need variant handling (unions). Choosing incorrectly produces either overly strict types that reject valid data or permissive types that accept invalid shapes.
type BaseButtonProps = {
label: string;
disabled?: boolean;
};
type ClickableProps = {
onClick: () => void;
};
type LinkProps = {
href: string;
target?: "_blank" | "_self";
};
type Button = BaseButtonProps & ClickableProps;
type LinkButton = BaseButtonProps & LinkProps;
const actionButton: Button = {
label: "Submit",
onClick: () => console.log("submitted"),
}; // ✓ Click handler required
const navButton: LinkButton = {
label: "Learn More",
href: "/docs",
target: "_blank",
}; // ✓ Link props requiredThe intersections compose cleanly because each type contributes non-conflicting properties. A button can have a label, a disabled state, and either a click handler or a link destination. Teams can build rich component APIs from smaller prop fragments without duplicating definitions.
The failure mode appears when developers try to represent "button or link" as an intersection instead of a union. The naive approach produces a type requiring both onClick and href, which is usually wrong.
// ❌ Wrong: requires both click handler AND link destination
type ConfusedButton = BaseButtonProps & ClickableProps & LinkProps;
const broken: ConfusedButton = {
label: "Broken",
onClick: () => {}, // Both required
href: "/nowhere", // Both required
}; // ✓ Compiles but semantically wrongThe compiler accepts it because no property signatures conflict. But the component logic likely expects "either a click handler or a link destination," not both. The intersection over-constrains the type, forcing developers to provide meaningless values.
The correct pattern is a discriminated union with a variant tag that matches the component's runtime behavior.
type ButtonVariant =
| ({ variant: "action" } & BaseButtonProps & ClickableProps)
| ({ variant: "link" } & BaseButtonProps & LinkProps);
function renderButton(props: ButtonVariant) {
if (props.variant === "action") {
return <button onClick={props.onClick}>{props.label}</button>;
} else {
return <a href={props.href} target={props.target}>{props.label}</a>;
}
}The union enforces "exactly one variant," and the discriminant enables exhaustive narrowing. TypeScript knows which properties are available in each branch, preventing access to href when variant is "action" and vice versa.
%% alt: Component props composition flow showing intersection for capabilities and union for variants
flowchart LR
A("Define base props")
B("Add capability props")
C("Intersection for mixing")
D("Union for variants")
E("Type-safe component API")
A --> B --> C
A --> B --> D
C --> E
D --> E
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
API response composition follows the same principle. Successful and failed responses are distinct variants, not mixed capabilities.
type ApiSuccess<T> = {
ok: true;
data: T;
timestamp: string;
};
type ApiError = {
ok: false;
error: string;
code: number;
};
type ApiResult<T> = ApiSuccess<T> | ApiError;
async function fetchUser(id: string): Promise<ApiResult<{ name: string }>> {
try {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { ok: true, data, timestamp: new Date().toISOString() };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Unknown error",
code: 500,
};
}
}
const result = await fetchUser("123");
if (result.ok) {
console.log(result.data.name); // ✓ TypeScript knows 'data' exists
} else {
console.error(result.error); // ✓ TypeScript knows 'error' exists
}The discriminant ok enables safe narrowing. Trying to use an intersection here (ApiSuccess & ApiError) would require ok to be both true and false, collapsing the type to never and breaking all type safety.
In other words, intersections mix capabilities into a single value, while unions represent distinct alternatives. Component props often need both: intersections to compose capabilities, unions to represent variants. API responses almost always need unions because success and failure are mutually exclusive states. Choosing correctly determines whether the type system enforces correctness or silently allows bugs.
Practical Guidelines: When to Use and When to Avoid
The decision to use intersection types comes down to three questions: Are the types contributing non-conflicting properties? Is the goal to enforce multiple capabilities simultaneously? Can the runtime value actually satisfy all constraints at once? If any answer is no, intersections are the wrong tool.
Use intersections when:
- Mixing capabilities or traits into a single type (e.g.,
Auditable & Deletable) - Composing non-overlapping property sets (e.g.,
WithId & WithTimestamp) - Enforcing that a value must satisfy multiple contracts (e.g.,
Serializable & Comparable) - Building rich domain models from reusable fragments
Avoid intersections when:
- Property signatures conflict (same name, incompatible types)
- Representing "one of several shapes" (use unions instead)
- The runtime value cannot simultaneously satisfy all types
- The goal is optional capabilities (use optional properties or unions)
%% alt: Decision flow for choosing intersection vs union types
flowchart LR
A("Need to compose types?")
B("Do properties conflict?")
C("Use union with discriminant")
D("Are capabilities simultaneous?")
E("Use intersection")
F("Use union")
A --> B
B -->|"Yes"| C
B -->|"No"| D
D -->|"Yes"| E
D -->|"No"| F
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Validate intersections with a never check during development. The conditional type pattern catches collapses before they escape into production.
type AssertNotNever<T> = [T] extends [never]
? { error: "Type resolved to never - check for conflicts" }
: T;
type Validated = AssertNotNever<NumericId & StringId>;
// Validated = { error: "Type resolved to never - check for conflicts" }The error message appears in intellisense and type errors, alerting developers immediately. Teams can build this into type utilities or CI checks to prevent never types from reaching production.
When intersections fail, the fix is usually one of three patterns:
- Remove the conflict: Rename properties or split types so signatures align
- Use a union: Represent the variants as a discriminated union instead
- Redesign the hierarchy: Factor out shared properties into a base type
// Before: Conflicting signatures
type ApiResponseV1 = { status: number };
type ApiResponseV2 = { status: string };
type Combined = ApiResponseV1 & ApiResponseV2; // never
// Fix 1: Rename properties
type ApiResponseV1Fixed = { statusCode: number };
type ApiResponseV2Fixed = { statusText: string };
type CombinedFixed = ApiResponseV1Fixed & ApiResponseV2Fixed; // ✓
// Fix 2: Use a union
type ApiResponse = ApiResponseV1 | ApiResponseV2; // ✓
// Fix 3: Extract shared base
type BaseResponse = { timestamp: string };
type NumericResponse = BaseResponse & { status: number };
type TextResponse = BaseResponse & { status: string };
type ApiResponseUnion = NumericResponse | TextResponse; // ✓The implication here is that intersections are a tool, not a default. Developers should reach for them consciously when the constraints make sense, not reflexively when combining types. The type system will accept nonsensical intersections without warning; it is the developer's job to ensure the composition is valid.
This matters because TypeScript's flexibility allows teams to encode domain invariants in types, but only if the types match reality. An intersection claiming "this value is both A and B" must reflect a runtime truth, not a wishful assumption. When it does, intersections deliver powerful, composable type safety. When it does not, they silently break every guarantee the type system provides.
Frequently Asked Questions
What happens when an intersection type resolves to never?
When an intersection resolves to never due to conflicting property signatures, the type becomes the bottom type, and TypeScript's assignability rules invert: every value becomes assignable to never. This silently breaks type safety because the compiler accepts any value instead of enforcing the intended constraints. The fix is to detect the never resolution using conditional types or redesign the intersection to eliminate conflicts.
When should I use an intersection instead of extending an interface?
Use an intersection when composing types defined elsewhere or when mixing multiple traits into a single type. Use interface extension when building a clear hierarchy where one interface semantically "is a" specialization of another. Intersections are more flexible for ad-hoc composition, while interface extension signals intentional relationships and enables declaration merging.
Can intersection types handle optional properties correctly?
Yes, intersections handle optional properties cleanly as long as the signatures do not conflict. An intersection { a?: string } & { b?: number } produces { a?: string; b?: number }. If the same optional property appears in both types with compatible signatures, the intersection preserves it. Conflicts (e.g., { a?: string } & { a?: number }) still resolve to never.
How do I debug an intersection that produces unexpected types?
Wrap the intersection in a type alias and hover over it in your editor to see the resolved type. Use conditional types to check for never: type Check<T> = [T] extends [never] ? "never" : T. If the intersection resolves to never, examine property names for conflicts. If it produces a valid type but behaves unexpectedly, check for subtle signature mismatches (e.g., string vs string | undefined).
Why do discriminated unions work better than intersections for variant types?
Discriminated unions enable exhaustive type narrowing based on a shared tag property, allowing TypeScript to know which variant is active in each code path. Intersections require a value to satisfy all types simultaneously, which is impossible for mutually exclusive variants. A union of intersections (e.g., ({ type: "a" } & PropsA) | ({ type: "b" } & PropsB)) combines both patterns, enabling variant handling with capability mixing.
Conclusion: Making Intersection Types Work for You
Intersection types deliver powerful composition when developers understand their set-theoretic behavior and apply them to compatible structures. The distinction between "mixing capabilities" and "representing variants" determines whether intersections enforce correctness or silently break type safety. Teams that validate intersections for conflicts, choose unions for mutually exclusive shapes, and design compatible property signatures unlock composable, maintainable type systems that catch bugs at compile time.
That covers the essential patterns for intersection type composition. Apply these in production and the difference will be immediate: fewer runtime type errors, richer domain models, and type safety that actually protects you instead of lying silently when constraints become impossible. For deeper TypeScript patterns, see Create a Modern TypeScript JavaScript Library for 2023 and Biome vs OxLint Comparison 2026.