Welcome back, intrepid TypeScript explorer! In our previous chapters, you’ve mastered the basics of declaring variables, defining functions, and creating your own custom types with type aliases and interface declarations. You’re building a solid foundation, and that’s fantastic!
Today, we’re going to unlock even more power and flexibility in TypeScript by learning how to combine and refine types. Imagine being able to say, “this variable can be either a number or a string,” or “this object must have the properties of both this type and that type.” That’s exactly what Union Types and Intersection Types allow us to do! We’ll also dive into Enums, a super handy way to define a set of related constants, making your code more readable and less prone to errors.
By the end of this chapter, you’ll be able to model more complex data structures and logic with precision, making your applications more robust and easier to maintain. This is a crucial step towards writing truly production-ready TypeScript code, so let’s jump in!
Core Concepts: The Building Blocks of Combined Types
Before we start writing code, let’s get a crystal-clear understanding of what these concepts are and why they’re so powerful.
6.1 Union Types: The “OR” Operator for Types (|)
Think of a union type as a way to declare that a variable or parameter can hold a value of one of several specified types. It’s like saying, “This box can contain either an apple or an orange.”
What it is: A union type is formed by combining two or more types with the vertical bar (|) symbol. For example, string | number means a value can be either a string or a number.
Why it matters:
- Flexibility: It allows you to write functions that can handle different kinds of input gracefully.
- Type Safety: While flexible, TypeScript still ensures you only access properties or methods common to all types in the union, or forces you to “narrow” the type before accessing specific ones. This prevents runtime errors!
- API Design: When building libraries or APIs, union types are excellent for defining parameters that accept various data formats.
Analogy: Imagine a universalRemote that can control either a TV or a SoundSystem. It can’t control both simultaneously, but it has the capability for either.
6.2 Intersection Types: The “AND” Operator for Types (&)
If union types represent “OR,” then intersection types represent “AND.” They allow you to combine multiple types into one, creating a new type that has all the properties of the combined types.
What it is: An intersection type is formed by combining two or more types with the ampersand (&) symbol. For example, Person & Employee means a value must have all the properties of Person AND all the properties of Employee.
Why it matters:
- Composition: It’s a powerful way to compose new types from existing ones without inheritance. You can mix and match functionalities or attributes.
- Extensibility: You can easily extend existing types with new properties or methods.
- Strictness: The resulting type is strictly defined by the sum of its parts, ensuring all required properties are present.
Analogy: Think of a SmartWatch that is both a FitnessTracker and a NotificationDevice. It has all the capabilities of a fitness tracker and all the capabilities of a notification device.
6.3 Enums: Named Constants for Clarity (enum)
Enums (short for “enumerations”) are a fantastic feature that allows you to define a set of named constants. They make your code much more readable and less prone to “magic string” or “magic number” errors.
What it is: An enum is a special type that represents a fixed set of values. By default, they are numeric, but you can also define string enums.
Why it matters:
- Readability: Instead of using raw numbers (like
0for “Pending”,1for “Approved”), you use meaningful names (e.g.,Status.Pending,Status.Approved). - Type Safety: TypeScript ensures you can only assign values from the defined enum set, catching errors at compile time.
- Maintainability: If a constant value needs to change, you only update it in one place (the enum definition).
Types of Enums:
- Numeric Enums (default): Each member is assigned a numeric value, starting from 0 by default.
- String Enums: Each member is assigned a string literal. Generally preferred for better debugging and readability.
const enum: A special enum type that is completely removed at compile time, leading to slightly smaller bundle sizes when the enum values are inlined. Use them when you don’t need the runtime object.
Best Practice (2025): While numeric enums are the default, String Enums are generally preferred in modern TypeScript development. They offer better readability in debugging and prevent tricky issues where numeric values might accidentally match other numbers in your code.
Step-by-Step Implementation: Bringing Types to Life
Let’s get our hands dirty and see these concepts in action! Open your src/index.ts file or create a new one. Remember, we’re using TypeScript v5.9.3 (the latest stable as of December 2025).
6.3.1 Working with Union Types
We’ll start with a simple function that can handle different types of input.
Define a function that accepts a union type:
Add the following code to your
src/index.tsfile:// src/index.ts /** * Processes a user ID, which can be either a number or a string. * @param id The user's identifier. */ function processUserId(id: number | string) { console.log(`Processing ID: ${id}`); }- Explanation: Here,
id: number | stringtells TypeScript that theidparameter can either be anumberor astring. TypeScript will allow you to pass either type.
- Explanation: Here,
Call the function with different types:
Let’s test it out. Add these lines:
// ... (previous code) processUserId(12345); processUserId("user-abc-789"); // processUserId(true); // Uncommenting this would cause a compile-time error!- Explanation: You can see we successfully pass both a number and a string. If you uncomment the
processUserId(true)line, TypeScript will immediately flag it as an error becausebooleanis not part of thenumber | stringunion. Fantastic type safety!
- Explanation: You can see we successfully pass both a number and a string. If you uncomment the
Type Narrowing with
typeof:What if we need to do something specific based on the type? For example, if it’s a number, maybe we want to increment it, but if it’s a string, we want to convert it to uppercase. This is where type narrowing comes in.
Modify your
processUserIdfunction:// ... (previous code) function processUserId(id: number | string) { console.log(`Processing ID: ${id}`); // Let's narrow the type! if (typeof id === 'string') { // Inside this block, TypeScript knows 'id' is a string! console.log(`ID is a string: ${id.toUpperCase()}`); } else { // Inside this block, TypeScript knows 'id' is a number! console.log(`ID is a number: ${id * 2}`); } } processUserId(12345); processUserId("user-abc-789");- Explanation:
- The
if (typeof id === 'string')check acts as a type guard. - Inside the
ifblock, TypeScript intelligently narrows the type ofidfromnumber | stringto juststring. This allows you to safely call string-specific methods liketoUpperCase(). - In the
elseblock, TypeScript knowsidmust be anumber(since it’s not a string), so you can perform numeric operations like multiplication.
- The
- Explanation:
6.3.2 Working with Intersection Types
Now let’s explore combining types using the intersection operator.
Define a couple of base interfaces:
Add these interfaces to your
src/index.tsfile:// ... (previous code) interface HasName { name: string; } interface HasEmail { email: string; }- Explanation: We’ve created two simple interfaces. One requires a
name, the other anemail.
- Explanation: We’ve created two simple interfaces. One requires a
Create an intersection type:
Now, let’s combine them into a new type called
ContactInfo.// ... (previous code) type ContactInfo = HasName & HasEmail;- Explanation:
ContactInfonow requires both anameproperty (fromHasName) and anemailproperty (fromHasEmail). It’s like saying, “This type must satisfyHasNameANDHasEmailsimultaneously.”
- Explanation:
Create an object using the intersection type:
Let’s make an object that adheres to our new
ContactInfotype.// ... (previous code) const personA: ContactInfo = { name: "Alice Wonderland", email: "alice@example.com", }; console.log(personA); // console.log(personA.age); // This would be an error! 'age' doesn't exist on ContactInfo.- Explanation: We successfully created
personAbecause it has bothnameandemail. If you tried to add anageproperty without extendingContactInfofurther, TypeScript would warn you, asageis not part ofHasNameorHasEmail.
- Explanation: We successfully created
Another example: combining more complex types:
Let’s say we have a
Productand aDiscountableitem.// ... (previous code) interface Product { id: string; price: number; } interface Discountable { discountPercentage: number; applyDiscount(): number; } type DiscountedProduct = Product & Discountable; const laptop: DiscountedProduct = { id: "LT-2025", price: 1200, discountPercentage: 0.15, // 15% off applyDiscount() { return this.price * (1 - this.discountPercentage); } }; console.log(`Original laptop price: $${laptop.price}`); console.log(`Discounted laptop price: $${laptop.applyDiscount().toFixed(2)}`);- Explanation:
DiscountedProductnow hasid,price,discountPercentage, and theapplyDiscountmethod. This shows how intersection types allow you to build rich, composable types.
- Explanation:
6.3.3 Working with Enums
Finally, let’s explore enums to make our constant values more expressive.
Numeric Enum (default behavior):
// ... (previous code) enum Direction { Up, // 0 Down, // 1 Left, // 2 Right // 3 } function move(direction: Direction) { switch (direction) { case Direction.Up: console.log("Moving upwards!"); break; case Direction.Down: console.log("Going down..."); break; case Direction.Left: console.log("Turning left."); break; case Direction.Right: console.log("Turning right."); break; default: console.log("Unknown direction."); } } move(Direction.Up); move(Direction.Right); // move(5); // This would typically be an error, but numeric enums can be tricky! // TypeScript allows assigning numbers if they *could* be enum values, // which is a common pitfall. Stick to enum members for safety.- Explanation:
enum Directiondefines a set of named constants. By default,Upis0,Downis1, and so on.- The
movefunction expects aDirectionenum member, making the code much more readable than passing raw numbers. - Important Note on Numeric Enums: While
move(5)might compile without error in some strictness settings (because5could theoretically be a valid numeric enum value), it’s a bad practice. Always pass the enum member directly (Direction.Up) for true type safety and readability. This is one reason why string enums are often preferred!
- Explanation:
String Enum (recommended):
Let’s define a string enum for HTTP status codes.
// ... (previous code) enum HttpStatusCode { OK = "OK", Created = "CREATED", BadRequest = "BAD_REQUEST", NotFound = "NOT_FOUND", InternalServerError = "INTERNAL_SERVER_ERROR" } function handleResponse(status: HttpStatusCode, message: string) { console.log(`Handling response - Status: ${status}, Message: ${message}`); if (status === HttpStatusCode.OK || status === HttpStatusCode.Created) { console.log("Operation successful!"); } else if (status === HttpStatusCode.NotFound) { console.log("Resource not found."); } else { console.log("An error occurred."); } } handleResponse(HttpStatusCode.OK, "Data fetched successfully."); handleResponse(HttpStatusCode.NotFound, "User 'xyz' not found."); // handleResponse("CUSTOM_ERROR", "Something went wrong."); // This would be a compile-time error! Good!- Explanation:
enum HttpStatusCodenow assigns explicit string values.- When you log
status, you’ll see “OK” or “NOT_FOUND”, which is much clearer than0or3. - Trying to pass a random string like
"CUSTOM_ERROR"will result in a compile-time error, ensuring type safety. This makes String Enums very robust.
- Explanation:
const enumfor optimization:For scenarios where you only need the enum values at compile time and don’t need a runtime JavaScript object,
const enumis a great choice.// ... (previous code) const enum LogLevel { DEBUG, INFO, WARN, ERROR } function logMessage(level: LogLevel, message: string) { if (level === LogLevel.ERROR) { console.error(`[ERROR] ${message}`); } else if (level === LogLevel.WARN) { console.warn(`[WARN] ${message}`); } else { // Note: For numeric const enums, LogLevel[level] might not work at runtime if the enum is fully inlined. // If you need the string name, use a String Enum or map it manually. // For demonstration, we'll assume a development environment where it might be accessible or handle string enums. // A safer approach for numeric const enums requiring string names at runtime is to avoid `const enum` // or use a separate mapping object. console.log(`[${LogLevel[level] || 'UNKNOWN'}] ${message}`); } } logMessage(LogLevel.INFO, "User logged in."); logMessage(LogLevel.ERROR, "Failed to connect to database.");- Explanation:
- The
constkeyword beforeenumtells TypeScript to inline the enum values directly into the JavaScript output wherever they are used. This means noLogLevelobject will exist at runtime, which can slightly reduce bundle size. - When transpiled,
LogLevel.INFOmight become0. The expressionLogLevel[level](e.g.,LogLevel[0]) for numericconst enumvalues is often problematic at runtime because the reverse mapping (0to"INFO") is usually stripped. For production code, if you need the string representation of aconst enum’s numeric value, it’s generally better to use astring enumor have a separate mapping object. The example includes|| 'UNKNOWN'as a fallback for robustness.
- The
- Explanation:
Mini-Challenge: The Event Processor
Alright, your turn to apply what you’ve learned!
Challenge: You’re building an event processing system.
- Define a String Enum called
EventTypewith at least three values (e.g.,Login,Logout,Purchase). - Define two interfaces:
UserEventBase: Requires auserId: stringandtimestamp: Date.ProductEventBase: Requires aproductId: stringandquantity: number.
- Create an Intersection Type called
LoginEventthat combinesUserEventBasewith a new propertyipAddress: stringand a literal type propertytype: EventType.Login. - Create an Intersection Type called
PurchaseEventthat combinesUserEventBase,ProductEventBase, and a literal type propertytype: EventType.Purchase. - Define a Union Type called
AnyEventthat can be eitherLoginEventorPurchaseEvent. - Write a function
processEvent(event: AnyEvent)that:- Logs the
event.type. - Uses type narrowing (specifically, a discriminated union check on the
typeproperty) to determine if the event is aLoginEventor aPurchaseEvent. - If it’s a
LoginEvent, log theipAddress. - If it’s a
PurchaseEvent, log theproductIdandquantity.
- Logs the
Hint: To differentiate between LoginEvent and PurchaseEvent within AnyEvent, you’ll use the type property you added to each. This is called a discriminated union, and it’s super powerful for type narrowing!
What to observe/learn:
- How to combine multiple interfaces using intersection.
- How to create a union of these combined types.
- How to use a common “discriminator” property (like
type) for effective type narrowing within a union.
// Your challenge code goes here!
// You can delete or comment out previous examples if you want a clean slate for the challenge.
// 1. Define EventType enum
enum EventType {
Login = "LOGIN",
Logout = "LOGOUT",
Purchase = "PURCHASE"
}
// 2. Define UserEventBase and ProductEventBase interfaces
interface UserEventBase {
userId: string;
timestamp: Date;
}
interface ProductEventBase {
productId: string;
quantity: number;
}
// 3. Create LoginEvent intersection type
type LoginEvent = UserEventBase & {
ipAddress: string;
type: EventType.Login; // Discriminator property!
};
// 4. Create PurchaseEvent intersection type
type PurchaseEvent = UserEventBase & ProductEventBase & {
type: EventType.Purchase; // Discriminator property!
};
// 5. Define AnyEvent union type
type AnyEvent = LoginEvent | PurchaseEvent;
// 6. Write processEvent function
function processEvent(event: AnyEvent) {
console.log(`\nProcessing event of type: ${event.type}`);
console.log(`User ID: ${event.userId}, Timestamp: ${event.timestamp.toISOString()}`);
// Type narrowing using the 'type' discriminator property
if (event.type === EventType.Login) {
// Inside this block, TypeScript knows 'event' is a LoginEvent
console.log(`Login event - IP Address: ${event.ipAddress}`);
} else if (event.type === EventType.Purchase) {
// Inside this block, TypeScript knows 'event' is a PurchaseEvent
console.log(`Purchase event - Product ID: ${event.productId}, Quantity: ${event.quantity}`);
} else {
// This 'else' block should ideally be unreachable if all union members are covered
// by the 'type' discriminator. This is a common pattern for exhaustive checks.
console.log("Unknown event type encountered.");
}
}
// Test your function!
const myLoginEvent: LoginEvent = {
userId: "user_123",
timestamp: new Date(),
ipAddress: "192.168.1.100",
type: EventType.Login
};
processEvent(myLoginEvent);
const myPurchaseEvent: PurchaseEvent = {
userId: "user_456",
timestamp: new Date(),
productId: "PROD_XYZ",
quantity: 2,
type: EventType.Purchase
};
processEvent(myPurchaseEvent);
// Example of an event not covered by the current union (will cause type error)
// const myLogoutEvent: UserEventBase & { type: EventType.Logout } = {
// userId: "user_789",
// timestamp: new Date(),
// type: EventType.Logout
// };
// processEvent(myLogoutEvent); // Error: Argument of type '{ userId: string; timestamp: Date; type: EventType.Logout; }' is not assignable to parameter of type 'AnyEvent'.
Common Pitfalls & Troubleshooting
Even with these powerful features, it’s easy to stumble. Here are a few common traps and how to avoid them:
Forgetting to Narrow Union Types:
- Pitfall: You have
let value: string | number;and then try to callvalue.toUpperCase()without a type guard. TypeScript will complain becausenumberdoesn’t havetoUpperCase(). - Solution: Always use type guards (
typeof,instanceof, or property checks for discriminated unions) to narrow down the type before accessing type-specific members.
function greet(name: string | null) { // console.log(name.toUpperCase()); // Error: 'name' could be null if (name) { // Type guard! console.log(name.toUpperCase()); // OK, 'name' is string here } }- Pitfall: You have
Confusing Union and Intersection Types:
- Pitfall: Thinking
TypeA | TypeBmeans “a type with all properties of A and B” (which is intersection), orTypeA & TypeBmeans “a type that is either A or B” (which is union). - Solution: Remember the “OR” vs. “AND” analogy.
A | B: A value that is either A or B. It has the union of properties that are common to A and B, or requires narrowing to access specific ones.A & B: A value that is both A and B. It has the intersection (all) of properties from A and B.
- Important Edge Case: If
TypeAhasprop: stringandTypeBhasprop: number, thenTypeA & TypeBwill haveprop: string & number, which resolves toprop: never. This means you can’t create an object that satisfies this intersection for that property, as nothing can be both a string and a number simultaneously. TypeScript helps you catch these impossible types!
- Pitfall: Thinking
Over-relying on Numeric Enums or Not Using
const enumAppropriately:- Pitfall: Using numeric enums and accidentally passing a raw number that matches an enum value, leading to subtle bugs, or having a larger JS bundle because enums are runtime objects when not
const. - Solution:
- Prefer String Enums: For most use cases, string enums provide better debugging and explicit values.
- Use
const enum: If you only need the values at compile time and don’t require a runtime object (e.g., iterating over enum members),const enumwill inline the values and reduce your bundle size. Be aware of its limitations (e.g., you can’t useenum[value]at runtime for aconst enum).
- Pitfall: Using numeric enums and accidentally passing a raw number that matches an enum value, leading to subtle bugs, or having a larger JS bundle because enums are runtime objects when not
Summary: Your New Type-Combining Superpowers!
You’ve just leveled up your TypeScript skills significantly! Here’s a quick recap of what we covered:
- Union Types (
|): Allow a variable to hold values of multiple possible types. Great for flexible function arguments and handling diverse data. Remember to use type narrowing withtypeof,instanceof, or property checks (especially for discriminated unions) to safely work with union members. - Intersection Types (
&): Combine properties from multiple types into a single new type. Perfect for composing complex objects and extending existing types without traditional inheritance. - Enums (
enum): Provide a way to define a set of named constants, improving code readability, maintainability, and type safety by preventing “magic values.”- String Enums are generally recommended for their clarity and robust type checking.
const enumoffers compile-time optimization by inlining values, useful when a runtime object isn’t needed.
With these tools, you can now express much more intricate relationships between your data, making your TypeScript applications even more robust and developer-friendly.
What’s Next?
In the next chapter, we’ll dive into another cornerstone of flexible and reusable code: Generics! Get ready to write functions and classes that work with any type while maintaining full type safety. It’s going to be awesome!