Combining Types and Interfaces with & and | in TypeScript

When modeling application data in TypeScript, you quickly reach a point where basic types are not enough. You need to compose types together: combining user data with permissions, handling API responses that return either success data or an error payload, or extending existing third-party interfaces.
TypeScript gives us three primary tools to achieve this:
- Union Types (
|) - Intersection Types (
&) - Interface Inheritance (
extends)
In this guide, we will explore how each operator works, when to use them in real-world applications, and how to avoid common type composition pitfalls.
Let's start by looking at our baseline domain models.
1. The Baseline Interfaces 🏀
Imagine we are building a sports management dashboard. We have two separate interfaces that describe different domain aspects:
interface Player {
id: number;
name: string;
}
interface PlayerStats {
points: number;
position: string;
}Playerhandles identity information.PlayerStatshandles match performance metrics.
How do we combine them when a feature requires both or either of these structures? Let's explore Union Types first.
2. Union Types (|): Handling Multiple Possibilities 🔀
A Union Type (using the | operator) tells TypeScript: "This value can be of Type A OR Type B."
export type PlayerOrStats = Player | PlayerStats;A variable of type PlayerOrStats can be:
- An object satisfying
Player - An object satisfying
PlayerStats - An object satisfying both
Practical Examples
// ✅ 1. Valid: Only Player properties
const playerOnly: PlayerOrStats = {
id: 1,
name: "Diana Taurasi"
};
// ✅ 2. Valid: Only PlayerStats properties
const statsOnly: PlayerOrStats = {
points: 24,
position: "Guard"
};
// ✅ 3. Valid: Both sets of properties
const fullPlayer: PlayerOrStats = {
id: 2,
name: "Breanna Stewart",
points: 30,
position: "Forward"
};Accessing Properties in Union Types
When accessing properties on a union type, TypeScript only allows accessing properties that are common to all union members unless you narrow the type:
function printPlayerInfo(data: PlayerOrStats) {
// ❌ Error: Property 'name' does not exist on type 'PlayerStats'
// console.log(data.name);
// ✅ Solution: Narrow with the 'in' operator
if ("name" in data) {
console.log(`Player Name: ${data.name}`);
}
}If you want to learn all the ways to narrow union types safely at runtime, check out my deep-dive on how to check types in TypeScript.
Now that we understand unions, let's see what happens when we need an object that strictly requires all properties.
3. Intersection Types (&): Merging Multiple Types 🔗
An Intersection Type (using the & operator) combines multiple types into one. It tells TypeScript: "This value MUST satisfy Type A AND Type B."
export type CompletePlayer = Player & PlayerStats;With CompletePlayer, any assigned object must include all properties from both Player and PlayerStats:
// ❌ Error: Property 'points' and 'position' are missing
const incompletePlayer: CompletePlayer = {
id: 1,
name: "A'ja Wilson"
};
// ✅ Valid: Contains every required property from both interfaces
const validPlayer: CompletePlayer = {
id: 1,
name: "A'ja Wilson",
points: 28,
position: "Center"
};Intersection Gotcha: Incompatible Property Types (never)
Be cautious when intersecting types that declare the same property name with conflicting primitive types:
interface ServerResponseA {
status: string;
}
interface ServerResponseB {
status: number;
}
// ⚠️ 'status' becomes (string & number), which evaluates to 'never'
type MergedResponse = ServerResponseA & ServerResponseB;Because a property cannot simultaneously be a string and a number, the status field becomes never, making MergedResponse impossible to instantiate without a type assertion.
Now let's compare intersection types with interface inheritance.
4. interface extends vs Intersection (&) 🏛️
TypeScript interfaces provide an alternative syntax for composition: extends.
interface ExtendedPlayer extends Player {
points: number;
position: string;
}Both CompletePlayer (Player & PlayerStats) and ExtendedPlayer require the same final shape. Here is how they compare in practice:
| Feature | Interface Inheritance (extends) | Intersection Types (&) |
|---|---|---|
| Primary Use Case | Hierarchical object contracts and domain entities | Ad-hoc composition, utility types, and unions |
| Type Flexibility | Can only extend object shapes and statically known types | Can compose any types (primitives, unions, generics) |
| Compiler Performance | Faster caching by the TypeScript compiler | Computed dynamically on every evaluation |
| Conflict Detection | Emits a compiler error if property types mismatch | Merges conflicting primitives into never |
| Declaration Merging | Supported (interfaces with same name auto-merge) | Not supported on type aliases |
When to use extends:
- When designing your core domain entities and object hierarchies.
- When you want the TypeScript compiler to alert you immediately if a subtype attempts to override a parent property with an incompatible type.
When to use & (Intersection):
- When combining a type alias with an interface.
- When creating utility types or merging generic constraints in functions.
- When combining union members dynamically.
Next, let's look at one of the most powerful real-world patterns: Discriminated Unions.
5. Real-World Pattern: Discriminated Unions 🛡️
The most common architectural pattern combining unions and intersections in production is the Discriminated Union for asynchronous API states:
type LoadingState = { status: "loading" };
type SuccessState<T> = { status: "success"; data: T };
type ErrorState = { status: "error"; message: string };
type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;
function handleState(state: AsyncState<Player[]>) {
switch (state.status) {
case "loading":
return "Loading players...";
case "success":
return `Loaded ${state.data.length} players.`;
case "error":
return `Error: ${state.message}`;
}
}Because each type has a unique literal property (status), TypeScript automatically narrows the object inside each case branch with complete type safety and autocompletion.
Recap 🛠️
Here is a quick summary to guide your daily coding decisions:
- Union (
|): Use when a value can be one of several possible types (e.g.string | number, API request variants). - Intersection (
&): Use when you need to merge existing types, utility types, or generics into a single composite type. interface extends: Use for standard object-oriented hierarchies and clear domain entities.- Discriminated Unions: Combine literal discriminator tags with unions for robust state machines and API response handling.
For more hands-on TypeScript techniques, check out my articles on How to Check Types in TypeScript, 3 Ways of Type Transformation in TypeScript, and Why You Should Avoid Using 'any' in TypeScript!
Mastering the language of the web. From basic types to advanced generics and patterns.
Frequently Asked Questions
What is the difference between a Union type (|) and an Intersection type (&) in TypeScript?
A Union type (|) means a value can satisfy either TypeA OR TypeB (holding properties of at least one member). An Intersection type (&) merges types together, requiring a value to satisfy TypeA AND TypeB simultaneously.
When should I use interface extends instead of an Intersection type (&)?
Use interface extends when defining hierarchical object contracts within your domain model (it offers better compiler performance and clear error messages). Use Intersection types (&) when combining existing types, union members, or ad-hoc generic types.
What happens if two interfaces have conflicting property types in an intersection?
If two interfaces share a property with incompatible primitive types (e.g. id: string & id: number), TypeScript computes the property type as never, making it impossible to instantiate the object.
Can I combine union types with intersection types in TypeScript?
Yes. Intersecting a type with a union distributes the intersection across each union member (e.g., Base & (VariantA | VariantB) results in (Base & VariantA) | (Base & VariantB)), which is a common pattern for shared entity metadata.
Related Articles
How to Check Types in TypeScript (The Complete Guide)
Learn how to check and validate types in TypeScript at compile-time and runtime using typeof, instanceof, the in operator, discriminated unions, custom type guards, and Zod or Valibot.
3 Ways of Type Transformation in Typescript
When we use types or interfaces, the typescript compiler enforces the object fit with them to avoid runtime errors for missing fields. Sometimes we want the flexibility to create an object without breaking the contract with the interface type. For ex...
Why You Should Avoid Using 'any' in TypeScript
Why using any in TypeScript leads to hidden runtime bugs, and how to replace it with unknown, custom type guards, utility types, and the satisfies operator.
How to Detect and Fix Circular Dependencies in TypeScript
Learn why circular dependencies break TypeScript applications at runtime, how to detect them using Madge and ESLint, and how to fix them using type-only imports and clean architectural patterns.
Share this article
If you found this guide helpful, consider sharing it with your team or fellow developers.
Real Software. Real Lessons.
I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.