TypeScript's type system provides powerful tools for modeling complex data structures. Among these, union and intersection types are essential features that allow you to combine types in different ways. Understanding these concepts will significantly enhance your ability to write type-safe, flexible code.
Union types allow you to express that a value can be one of several types. They are created using the pipe (|) symbol.
Basic union type
// A variable that can be either a string or a numberlet id:string|number;id =101; // Validid ="A201"; // Valid// id = true; // Error: Type 'boolean' is not assignable to type 'string | number'
In this example, the variable id can hold either a string or a number, but not any other type such as boolean or object.
Working with Union Types
When working with union types, TypeScript will only allow operations that are valid for all possible types in the union:
Union type operations
functionprintId(id:string|number) {console.log(`ID: ${id}`);// Error: Property 'toUpperCase' does not exist on type 'string | number'.// Property 'toUpperCase' does not exist on type 'number'.// console.log(id.toUpperCase());}
To perform operations specific to one type, you need to narrow the type using type guards:
Type narrowing with union types
functionprintId(id:string|number) {console.log(`ID: ${id}`);// Type narrowing with typeofif (typeof id ==="string") {// In this block, TypeScript knows id is a stringconsole.log(id.toUpperCase()); } else {// In this block, TypeScript knows id is a numberconsole.log(id.toFixed(2)); }}
Union Types with Arrays
You can create unions of array types or arrays that can contain multiple types:
Union types with arrays
// An array of strings OR an array of numberslet data:string[] |number[];data = ["apple","banana","cherry"]; // Validdata = [1,2,3,4,5]; // Valid// data = [1, "two", 3]; // Error: Type 'string' is not assignable to type 'number'// An array that can contain both strings AND numberslet mixedData: (string|number)[];mixedData = [1,"two",3,"four"]; // ValidmixedData = ["one","two","three"]; // ValidmixedData = [1,2,3]; // Valid// mixedData = [true, 1, "three"]; // Error: Type 'boolean' is not assignable to type 'string | number'
Note the difference between string[] | number[] (either an array of strings OR an array of numbers) and (string | number)[] (an array that can contain BOTH strings and numbers).
Discriminated Unions
Discriminated unions are a pattern where you use a common property (the "discriminant") to differentiate between union members. This makes it easier to work with complex union types:
Discriminated unions
// Define interfaces with a common "kind" propertyinterfaceCircle { kind:"circle"; radius:number;}interfaceSquare { kind:"square"; sideLength:number;}interfaceTriangle { kind:"triangle"; base:number; height:number;}// Create a union typetypeShape=Circle|Square|Triangle;// Function that uses the discriminant to handle each shapefunctioncalculateArea(shape:Shape):number {switch (shape.kind) {case"circle":returnMath.PI*shape.radius **2;case"square":returnshape.sideLength **2;case"triangle":return (shape.base *shape.height) /2;default:// TypeScript's exhaustiveness checking helps ensure all cases are handledconst_exhaustiveCheck:never= shape;return _exhaustiveCheck; }}// Usage examplesconstmyCircle:Circle= { kind:"circle", radius:5 };console.log(calculateArea(myCircle)); // 78.54...constmySquare:Square= { kind:"square", sideLength:4 };console.log(calculateArea(mySquare)); // 16
The never type in the default case helps with exhaustiveness checking. If you later add a new shape to the union but forget to handle it in the function, TypeScript will give you a compile-time error.
Practical Example: API Response Handling
Union types are excellent for handling different response types from APIs:
API Response handling
// Define different response typesinterfaceSuccessResponse { status:"success"; data: { id:number; name:string;// other properties... };}interfaceErrorResponse { status:"error"; error: { code:number; message:string; };}interfaceLoadingResponse { status:"loading";}// Combine into a union typetypeApiResponse=SuccessResponse|ErrorResponse|LoadingResponse;// Function to handle different response typesfunctionhandleResponse(response:ApiResponse) {switch (response.status) {case"success":console.log(`Data received: ${response.data.name}`);returnresponse.data;case"error":console.error(`Error ${response.error.code}: ${response.error.message}`);thrownewError(response.error.message);case"loading":console.log("Data is loading...");returnnull; }}// Example usageconstsuccessResponse:SuccessResponse= { status:"success", data: { id:123, name:"User One", },};handleResponse(successResponse); // "Data received: User One"
Intersection types allow you to combine multiple types into one. This is done using the ampersand (&) symbol. An intersection type contains all features from all the constituent types.
Basic intersection type
// Define two separate typestypePerson= { name:string; age:number;};typeEmployee= { companyId:string; role:string;};// Combine them with an intersectiontypeEmployeeWithPersonalInfo=Person&Employee;// The resulting type has all properties from both typesconstemployee:EmployeeWithPersonalInfo= { name:"John Smith", age:32, companyId:"E123", role:"Developer",};
The EmployeeWithPersonalInfo type contains all properties from both Person and Employee.
Use Cases for Intersection Types
Intersection types are particularly useful when you want to:
Extend or add capabilities to existing types:
Extending types
// Base configuration typetypeBaseConfig= { endpoint:string; timeout:number;};// Authentication configurationtypeAuthConfig= { apiKey:string; username:string;};// Create a complete configuration that has all propertiestypeFullConfig=BaseConfig&AuthConfig;functioninitializeApp(config:FullConfig) {// Has access to all properties from both typesconsole.log(`Connecting to ${config.endpoint} with key ${config.apiKey}`);// ...}
When creating intersection types, be careful with properties that share the same name but have different types. If the types are not compatible, the property type becomes never:
Incompatible properties
typeTypeA= { x:number; y:string;};typeTypeB= { x:string; // Note: x is a string here, but a number in TypeA z:boolean;};// The intersection will have properties x, y, and z// But the type of x is the intersection of number & string, which is nevertypeTypeC=TypeA&TypeB;// This is impossible to create because x can't be both a number and a stringconstinstance:TypeC= { x:/* ??? */,// Error: Type 'number' is not assignable to type 'never' y:"hello", z:true};
In practice, you should avoid creating intersection types with incompatible properties.
When working with complex union or intersection types, type guards help you narrow down types to safely access type-specific properties.
Type guards with unions
typeAdmin= { role:"admin"; permissions:string[];};typeUser= { role:"user"; lastLogin:Date;};typeMember=Admin|User;// Using discriminated unionsfunctiondisplayMemberInfo(member:Member) {console.log(`Role: ${member.role}`);if (member.role ==="admin") {console.log(`Permissions: ${member.permissions.join(", ")}`); } else {console.log(`Last Login: ${member.lastLogin.toLocaleString()}`); }}// Using custom type guards with type predicatesfunctionisAdmin(member:Member): member isAdmin {returnmember.role ==="admin";}functionisUser(member:Member): member isUser {returnmember.role ==="user";}functiondisplayMemberInfoAlternative(member:Member) {if (isAdmin(member)) {console.log(`Admin with permissions: ${member.permissions.join(", ")}`); } elseif (isUser(member)) {console.log(`User last logged in on: ${member.lastLogin.toLocaleString()}`); }}
TypeScript's union and intersection types are powerful tools for creating flexible, type-safe code:
Union types (A | B) allow a value to be one of several types, providing flexibility while maintaining type safety
Intersection types (A & B) combine multiple types into one, creating a new type with all the features of the constituent types
Discriminated unions use a common property to distinguish between different members of a union type
Type guards help narrow down types when working with unions
Combining unions and intersections enables modeling of complex data structures with precise type checking
Understanding when and how to use these type combinations is crucial for writing expressive, maintainable TypeScript code. Union types are ideal for representing values that could be of different types, while intersection types excel at combining features from multiple types into a single entity.
As you continue to explore TypeScript, you'll find these type constructs indispensable for modeling complex domains and ensuring your code behaves as expected.