
Chapter Outline
Advanced TypeScript Types: Unions Intersections and Generics
TypeScript's type system is one of its most powerful features, providing a robust way to define the structure and behavior of data in your applications. In this article, we'll dive into three advanced TypeScript types: unions, intersections, and generics. We'll explore how these types work, provide real-world examples, and discuss best practices for using them to build scalable enterprise solutions. Additionally, we'll touch on polymorphism, function overloading, and utility types.
Unions
Union types allow you to define a variable that can hold multiple types. This is particularly useful when a value can be one of several types.
typescript1function printId(id: number | string): void {2 if (typeof id === "string") {3 console.log(`Your ID is: ${id.toUpperCase()}`);4 } else {5 console.log(`Your ID is: ${id}`);6 }7}89printId(123); // Your ID is: 12310printId("abc123"); // Your ID is: ABC123
In this example:
- The
printIdfunction accepts a parameteridthat can be either anumberor astring. - We use type guards (
typeof) to handle each type differently.
Real-World Use Case: API Responses
Union types are useful when dealing with API responses that can return different types based on the request.
typescript1type ApiResponse = SuccessResponse | ErrorResponse;23interface SuccessResponse {4 success: true;5 data: any;6}78interface ErrorResponse {9 success: false;10 error: string;11}1213function handleApiResponse(response: ApiResponse): void {14 if (response.success) {15 console.log("Data:", response.data);16 } else {17 console.error("Error:", response.error);18 }19}
Intersections
Intersection types allow you to combine multiple types into one. This is useful when you want a variable to conform to multiple type constraints.
Example: Intersection Types
typescript1interface Person {2 name: string;3}45interface Employee {6 employeeId: number;7}89type EmployeePerson = Person & Employee;1011const employee: EmployeePerson = {12 name: "John Doe",13 employeeId: 12345,14};1516console.log(employee); // { name: "John Doe", employeeId: 12345 }
In this example:
- The
EmployeePersontype is an intersection ofPersonandEmployee. - An
employeeobject must have properties from bothPersonandEmployee.
Real-World Use Case: Combining Interfaces
Intersection types are beneficial when combining multiple interfaces to create a more complex type.
typescript1interface Admin {2 permissions: string[];3}45type AdminEmployee = Person & Employee & Admin;67const adminEmployee: AdminEmployee = {8 name: "Jane Smith",9 employeeId: 67890,10 permissions: ["read", "write", "delete"],11};1213console.log(adminEmployee);14// { name: "Jane Smith", employeeId: 67890, permissions: ["read", "write", "delete"] }
Generics
Generics allow you to create reusable components that work with any data type. They provide a way to define functions, classes, and interfaces that are type-safe and flexible.
Generic Functions
A generic function allows you to define a function that can work with any data type.
typescript1function identity<T>(arg: T): T {2 return arg;3}45console.log(identity<number>(42)); // 426console.log(identity<string>("Hello")); // Hello
In this example:
- The
identityfunction is a generic function that accepts a parameter of typeTand returns a value of the same type. - We can call
identitywith different types (e.g.,number,string).
Generic Functions with Constraints
You can use constraints to restrict the types that can be used with your generic function.
typescript1function loggingIdentity<T extends { length: number }>(arg: T): T {2 console.log(arg.length);3 return arg;4}56loggingIdentity("Hello, World!"); // 137loggingIdentity([1, 2, 3, 4, 5]); // 58loggingIdentity({ length: 10, value: "foo" }); // 10910// loggingIdentity(42); // Error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
In this example:
- The
loggingIdentityfunction is constrained to types that have alengthproperty. - This allows you to use the function with strings, arrays, or any other type that has a
lengthproperty.
Generic Functions with Multiple Type Parameters
You can define generic functions with multiple type parameters to handle more complex scenarios.
typescript1function merge<T, U>(obj1: T, obj2: U): T & U {2 return { ...obj1, ...obj2 };3}45const merged = merge({ name: "Alice" }, { age: 30 });6console.log(merged); // { name: "Alice", age: 30 }
In this example:
- The
mergefunction takes two generic type parameters,TandU. - It returns a new object that is the intersection of
TandU.
Generic Classes
A generic class allows you to create a class that can work with different data types.
typescript1class Box<T> {2 contents: T;34 constructor(contents: T) {5 this.contents = contents;6 }78 getContents(): T {9 return this.contents;10 }11}1213const numberBox = new Box<number>(123);14const stringBox = new Box<string>("Hello, World!");1516console.log(numberBox.getContents()); // 12317console.log(stringBox.getContents()); // Hello, World!
In this example:
- The
Boxclass has a generic type parameterT. - The
contentsproperty and thegetContentsmethod both use the typeT.
Generic Classes with Constraints
You can also use constraints with generic classes to restrict the types that can be used.
typescript1interface Identifiable {2 id: number;3}45class Repository<T extends Identifiable> {6 private items: T[] = [];78 add(item: T): void {9 this.items.push(item);10 }1112 getById(id: number): T | undefined {13 return this.items.find(item => item.id === id);14 }15}1617interface User extends Identifiable {18 name: string;19}2021const userRepository = new Repository<User>();2223userRepository.add({ id: 1, name: "Alice" });24userRepository.add({ id: 2, name: "Bob" });2526const user = userRepository.getById(1);27console.log(user); // { id: 1, name: "Alice" }
In this example:
- The
Repositoryclass is constrained to types that extend theIdentifiableinterface. - The
addmethod adds an item to the repository. - The
getByIdmethod retrieves an item by its ID.
Generic Classes with Multiple Type Parameters
You can also define generic classes with multiple type parameters.
typescript1class Pair<T, U> {2 constructor(public first: T, public second: U) {}34 getFirst(): T {5 return this.first;6 }78 getSecond(): U {9 return this.second;10 }11}1213const pair = new Pair<string, number>("Hello", 42);14console.log(pair.getFirst()); // Hello15console.log(pair.getSecond()); // 42
In this example:
- The
Pairclass has two generic type parameters,TandU. - The class has two properties,
firstandsecond, which are of typesTandU, respectively.
Generic Interfaces
Generic interfaces in TypeScript allow you to create flexible and reusable components that can work with different data types.
typescript1interface Container<T> {2 value: T;3}45const stringContainer: Container<string> = { value: "Hello, World!" };6const numberContainer: Container<number> = { value: 42 };78console.log(stringContainer.value); // Hello, World!9console.log(numberContainer.value); // 42
In this example:
- The
Containerinterface has a generic type parameterT. - The
valueproperty is of typeT, making the container adaptable to any type.
Generic Interfaces with Multiple Generic Types
You can define an interface with multiple generic type parameters to handle more complex scenarios.
typescript1interface Pair<T, U> {2 first: T;3 second: U;4}56const stringNumberPair: Pair<string, number> = { first: "One", second: 1 };7const booleanStringPair: Pair<boolean, string> = { first: true, second: "True" };89console.log(stringNumberPair); // { first: "One", second: 1 }10console.log(booleanStringPair); // { first: true, second: "True" }
In this example:
- The
Pairinterface has two generic type parameters,TandU. - This allows you to create pairs of different types.
Generic Interface for Functions
You can also use generic interfaces to define function types that are flexible with their parameter and return types.
typescript1interface Transformer<T, U> {2 (input: T): U;3}45const stringToNumber: Transformer<string, number> = (input) => parseInt(input, 10);6const numberToString: Transformer<number, string> = (input) => input.toString();78console.log(stringToNumber("123")); // 1239console.log(numberToString(456)); // "456"
In this example:
- The
Transformerinterface defines a function type that transforms a value of typeTto a value of typeU. - This can be useful for creating functions that perform type-safe transformations.
Real-World Example: Generic Repository Pattern
A common use case for generic interfaces is implementing the repository pattern in a way that works with various data models.
typescript1interface Repository<T> {2 add(item: T): void;3 getAll(): T[];4}56class MemoryRepository<T> implements Repository<T> {7 private items: T[] = [];89 add(item: T): void {10 this.items.push(item);11 }1213 getAll(): T[] {14 return this.items;15 }16}1718interface User {19 id: number;20 name: string;21}2223const userRepository: Repository<User> = new MemoryRepository<User>();2425userRepository.add({ id: 1, name: "Alice" });26userRepository.add({ id: 2, name: "Bob" });2728console.log(userRepository.getAll());29// [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
In this example:
- The
Repositoryinterface defines methods for adding and retrieving items. - The
MemoryRepositoryclass implements theRepositoryinterface. - This allows you to create repositories for different data models, like
User, in a type-safe manner.
Using Generic Constraints
Sometimes you want to impose constraints on the types that can be used with your generic interface. You can achieve this using extends.
typescript1interface Identifiable {2 id: number;3}45interface Repository<T extends Identifiable> {6 add(item: T): void;7 getAll(): T[];8}910class MemoryRepository<T extends Identifiable> implements Repository<T> {11 private items: T[] = [];1213 add(item: T): void {14 this.items.push(item);15 }1617 getAll(): T[] {18 return this.items;19 }20}2122interface User extends Identifiable {23 name: string;24}2526const userRepository: Repository<User> = new MemoryRepository<User>();2728userRepository.add({ id: 1, name: "Alice" });29userRepository.add({ id: 2, name: "Bob" });3031console.log(userRepository.getAll());32// [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
In this example:
- The
Identifiableinterface defines anidproperty. - The
RepositoryandMemoryRepositoryinterfaces useT extends Identifiableto enforce that the generic typeTmust have anidproperty.
Polymorphism
Polymorphism is a core concept in object-oriented programming that allows objects to be treated as instances of their parent class rather than their actual class. In TypeScript, polymorphism is achieved through inheritance and interfaces.
Example: Polymorphism with Classes
typescript1abstract class Animal {2 abstract makeSound(): void;3}45class Dog extends Animal {6 makeSound(): void {7 console.log("Woof!");8 }9}1011class Cat extends Animal {12 makeSound(): void {13 console.log("Meow!");14 }15}1617function createAnimalSound(animal: Animal): void {18 animal.makeSound();19}2021const dog = new Dog();22const cat = new Cat();2324createAnimalSound(dog); // Woof!25createAnimalSound(cat); // Meow!
In this example:
Animalis an abstract class with an abstract methodmakeSound.DogandCatextendAnimaland implementmakeSound.- The
createAnimalSoundfunction accepts anyAnimaltype and calls themakeSoundmethod, demonstrating polymorphism.
Function Overloading
Function overloading allows you to define multiple signatures for a function, providing different ways to call it based on the argument types.
typescript1function add(a: number, b: number): number;2function add(a: string, b: string): string;3function add(a: any, b: any): any {4 return a + b;5}67console.log(add(1, 2)); // 38console.log(add("Hello, ", "World!")); // Hello, World!
In this example:
- The
addfunction has two overloads: one for adding numbers and one for concatenating strings. - The implementation combines both cases using
anytype.
Utility Types
TypeScript provides several utility types to facilitate common type transformations. These utility types can help simplify and manage types in large codebases.
Example: Partial Readonly and Pick
typescript1interface User {2 id: number;3 name: string;4 email: string;5}67type PartialUser = Partial<User>;8type ReadonlyUser = Readonly<User>;9type UserNameAndEmail = Pick<User, "name" | "email">;1011const user: PartialUser = { id: 1 };12const readonlyUser: ReadonlyUser = { id: 1, name: "John", email: "john@example.com" };13const userNameAndEmail: UserNameAndEmail = { name: "John", email: "john@example.com" };1415console.log(user); // { id: 1 }16console.log(readonlyUser); // { id: 1, name: "John", email: "john@example.com" }17console.log(userNameAndEmail); // { name: "John", email: "john@example.com" }
Utility Types Overview
Partial<T>: Makes all properties inToptional.Readonly<T>: Makes all properties inTread-only.Pick<T, K>: Creates a type by picking a set of propertiesKfromT.
For a comprehensive list of utility types and more detailed examples, refer to the TypeScript documentation on Utility Types.
Best Practices
Type Guards
When using union types, it's essential to use type guards to ensure type safety.
typescript1function isString(value: any): value is string {2 return typeof value === "string";3}45function printValue(value: number | string): void {6 if (isString(value)) {7 console.log(`String value: ${value}`);8 } else {9 console.log(`Number value: ${value}`);10 }11}
Use Intersections Wisely
While intersection types are powerful, overusing them can lead to complex and hard-to-maintain code. Use them when you need to combine types logically.
Embrace Generics
Generics are crucial for writing reusable and type-safe code. Use them for creating flexible and generic data structures and algorithms.
Conclusion
Understanding and leveraging advanced TypeScript types like unions, intersections, and generics can significantly enhance your ability to build and manage scalable enterprise solutions. Polymorphism and function overloading further extend the flexibility and reusability of your code. Utility types simplify type transformations, making your codebase more maintainable.
By following best practices and using these types effectively, you can create robust and maintainable TypeScript applications. For further learning, consider exploring the TypeScript Handbook and other TypeScript resources.