TypeScript Union and Intersection Types: Combining Types Effectively
July 25, 2025
TypeScript's type system becomes truly powerful when you need to express relationships between different types. Whether you're handling API responses that could succeed or fail, building flexible component props, or creating data structures that evolve based on user interactions, you'll quickly discover that single types aren't enough.
The challenge appears everywhere: user input that might be valid or invalid, database queries that return data or errors, UI components that accept multiple prop combinations. These real-world scenarios demand a more sophisticated approach to type definitions.
Consider a user authentication system where login attempts return different responses. Success brings back a complete user profile with permissions and preferences, while failure returns just an error message with details about what went wrong. Your application needs to handle both scenarios gracefully.
TypeScript solves these challenges with two complementary tools: union types for "this OR that" situations, and intersection types for "this AND that" requirements.
These features let you model complex, real-world data relationships while maintaining the type safety that makes TypeScript valuable. By the end of this article, you'll understand how to combine types effectively to create robust, maintainable code that adapts to changing requirements.
When One Type Isn't Enough: Understanding Union Types
A union type represents a value that could be one of several types. You create them using the pipe symbol (|
), which reads naturally as "or." When you write string | number
, you're telling TypeScript "this value is either a string or a number."
Here's a concrete example from a typical ExplainThis article scenario:
function formatViewCount(views: string | number): string {
// TypeScript knows views could be either type
console.log(views); // This works - both types support console.log
// But this doesn't work:
return views.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'
}
The key here is that inside the function, TypeScript only lets you access properties and methods that exist on all possible types in the union. Since toUpperCase()
only exists on strings, not numbers, TypeScript prevents you from calling it directly.
This might seem restrictive at first, but it's actually protecting you from runtime errors. Without this safety, calling toUpperCase()
on a number would crash your application.
Union types shine when modeling real-world data that genuinely varies in structure. Consider an ExplainThis article that could be in draft or published state:
type DraftArticle = {
id: string;
title: string;
content: string;
status: "draft";
};
type PublishedArticle = {
id: string;
title: string;
content: string;
status: "published";
publishDate: Date;
viewCount: number;
};
type Article = DraftArticle | PublishedArticle;
function getArticleInfo(article: Article) {
// These properties exist on both types
console.log(`Title: ${article.title}`);
console.log(`Status: ${article.status}`);
// But publishDate only exists on PublishedArticle
console.log(article.publishDate); // Error!
}
This example demonstrates how union types let you model data that shares some properties but differs in others. Both article types have id
, title
, content
, and status
, but only published articles have publishDate
and viewCount
.
Making Union Types Practical: Type Guards
Union types would be fairly limited if you could only access common properties. The real power comes from TypeScript's ability to narrow types based on your code's logic. This process, called type narrowing, happens through type guards.
The simplest type guard uses the typeof
operator:
function formatViewCount(views: string | number): string {
if (typeof views === "string") {
// TypeScript now knows views is definitely a string
return views.toUpperCase(); // This works!
}
// TypeScript knows views must be a number here
return views.toLocaleString(); // And this works too!
}
TypeScript's control flow analysis is sophisticated enough to track type information through your conditional logic. Once you check typeof views === "string"
, TypeScript narrows the type inside that block to just string
. In the else block, it knows the value must be a number
.
For object unions, you can use property checks:
function displayArticle(article: Article) {
if (article.status === "published") {
// TypeScript narrows to PublishedArticle
console.log(`Published on: ${article.publishDate}`);
console.log(`Views: ${article.viewCount}`);
} else {
// TypeScript narrows to DraftArticle
console.log("This article is still in draft");
}
}
The status
property works as a discriminant because it has different literal values ("draft"
vs "published"
) in each type. TypeScript uses this information to determine which specific type you're working with.
You can also use the in
operator to check for property existence:
function displayArticle(article: Article) {
if ("publishDate" in article) {
// TypeScript knows this is PublishedArticle
console.log(`Published: ${article.publishDate.toDateString()}`);
}
}
This approach works because publishDate
only exists on PublishedArticle
, so its presence indicates which type you're dealing with.
Real-World Patterns: Discriminated Unions
One of the most powerful patterns combines union types with consistent discriminant properties. These discriminated unions (also called tagged unions) make type narrowing predictable and exhaustive.
Here's how you might model different types of notifications in ExplainThis:
type CommentNotification = {
type: "comment";
articleId: string;
commenterName: string;
commentText: string;
};
type LikeNotification = {
type: "like";
articleId: string;
likerName: string;
};
type FollowNotification = {
type: "follow";
followerName: string;
followerBio: string;
};
type Notification = CommentNotification | LikeNotification | FollowNotification;
// Here's what happens when you try to access the wrong properties:
function brokenFormatNotification(notification: Notification): string {
// This will cause TypeScript errors!
return `${notification.commenterName} did something`;
// Error: Property 'commenterName' does not exist on type 'Notification'
// Property 'commenterName' does not exist on type 'LikeNotification'
// Property 'commenterName' does not exist on type 'FollowNotification'
}
Why does this break? Think of it this way: when you receive a Notification
, TypeScript doesn't know which specific type it is yet. It could be any of the three types in the union. Since commenterName
only exists on CommentNotification
but not on LikeNotification
or FollowNotification
, TypeScript prevents you from accessing it directly.
This is TypeScript protecting you from a runtime error. If you tried to access commenterName
on a like notification, your code would crash because that property doesn't exist. The type system forces you to check which type you're dealing with first using the type
property like below.
function formatNotification(notification: Notification): string {
switch (notification.type) {
case "comment":
// TypeScript knows this is CommentNotification
return `${notification.commenterName} commented: "${notification.commentText}"`;
}
}
The type
property serves as a discriminant that lets TypeScript determine which specific notification type you're dealing with. This pattern is incredibly useful for state management, API responses, and any scenario where you have a finite set of related but distinct data shapes.
Combining Requirements: Intersection Types
While union types handle "or" scenarios, intersection types solve "and" problems. An intersection type combines multiple types into one that has all properties from each constituent type. You create them using the ampersand symbol (&
).
Consider building a user management system for ExplainThis where you need to combine different capability sets:
type User = {
id: string;
username: string;
email: string;
};
type Author = {
articlesWritten: number;
bio: string;
};
type Moderator = {
canDeleteComments: boolean;
canBanUsers: boolean;
};
// An author who is also a moderator
type AuthorModerator = User & Author & Moderator;
function setupAuthorModerator(user: AuthorModerator) {
// All properties from all three types are available
console.log(`User: ${user.username}`); // from User
console.log(`Articles: ${user.articlesWritten}`); // from Author
console.log(`Can ban: ${user.canBanUsers}`); // from Moderator
}
The resulting AuthorModerator
type has every property from User
, Author
, and Moderator
. This lets you compose types like building blocks, avoiding the need for complex inheritance hierarchies.
Intersection types are particularly useful for extending existing types with additional properties:
type BaseArticle = {
id: string;
title: string;
content: string;
};
type ArticleWithMetrics = BaseArticle & {
viewCount: number;
likeCount: number;
shareCount: number;
};
type ArticleWithComments = BaseArticle & {
comments: Comment[];
commentCount: number;
};
// You can even intersect intersections
type FullArticle = ArticleWithMetrics & ArticleWithComments;
Let's break down what's happening here step by step:
- BaseArticle defines the core properties every article needs: an ID, title, and content
- ArticleWithMetrics takes BaseArticle and adds engagement metrics using
&
. The result has all BaseArticle properties plus the three new metric properties - ArticleWithComments also starts with BaseArticle but adds comment-related properties instead
- FullArticle combines both enhanced versions, giving you an article type with metrics AND comments
Think of intersection types like adding features to a base model. If BaseArticle is your basic car, then ArticleWithMetrics is the same car with a premium sound system, and ArticleWithComments is the car with leather seats. FullArticle would be the car with both the premium sound system and leather seats.
Here's what a FullArticle object would look like:
const fullArticle: FullArticle = {
// From BaseArticle
id: "article-123",
title: "Understanding TypeScript",
content: "TypeScript is a powerful...",
// From ArticleWithMetrics
viewCount: 1250,
likeCount: 89,
shareCount: 23,
// From ArticleWithComments
comments: [
/* array of comments */
],
commentCount: 15,
};
This composition approach is more flexible than inheritance because you can mix and match capabilities as needed, and TypeScript ensures you satisfy all the requirements.
Bringing It All Together
Union and intersection types are fundamental tools for expressing complex type relationships in TypeScript. Union types (|
) handle "or" scenarios where values could be one of several types, while intersection types (&
) combine multiple types into one with all properties.
Type guards make union types practical by letting you narrow types based on conditional logic. Use typeof
, property checks, or the in
operator to help TypeScript understand which specific type you're working with. Discriminated unions with consistent discriminant properties create predictable, exhaustive type checking patterns.
Intersection types excel at composition, letting you build complex types from simpler building blocks without rigid inheritance hierarchies. This approach creates flexible, maintainable code that accurately models your domain while catching inconsistencies at compile time.
Together, these features transform TypeScript from a simple type checker into a powerful modeling language that adapts to real-world complexity while maintaining safety and clarity.