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.
function printId(id: number | string): void {
if (typeof id === "string") {
console.log(`Your ID is: ${id.toUpperCase()}`);
} else {
console.log(`Your ID is: ${id}`);
}
}
printId(123); // Your ID is: 123
printId("abc123"); // Your ID is: ABC123
In this example:
- The
printId
function accepts a parameterid
that can be either anumber
or 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.
type ApiResponse = SuccessResponse | ErrorResponse;
interface SuccessResponse {
success: true;
data: any;
}
interface ErrorResponse {
success: false;
error: string;
}
function handleApiResponse(response: ApiResponse): void {
if (response.success) {
console.log("Data:", response.data);
} else {
console.error("Error:", response.error);
}
}
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
interface Person {
name: string;
}
interface Employee {
employeeId: number;
}
type EmployeePerson = Person & Employee;
const employee: EmployeePerson = {
name: "John Doe",
employeeId: 12345,
};
console.log(employee); // { name: "John Doe", employeeId: 12345 }
In this example:
- The
EmployeePerson
type is an intersection ofPerson
andEmployee
. - An
employee
object must have properties from bothPerson
andEmployee
.
Real-World Use Case: Combining Interfaces
Intersection types are beneficial when combining multiple interfaces to create a more complex type.
interface Admin {
permissions: string[];
}
type AdminEmployee = Person & Employee & Admin;
const adminEmployee: AdminEmployee = {
name: "Jane Smith",
employeeId: 67890,
permissions: ["read", "write", "delete"],
};
console.log(adminEmployee);
// { 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.
function identity<T>(arg: T): T {
return arg;
}
console.log(identity<number>(42)); // 42
console.log(identity<string>("Hello")); // Hello
In this example:
- The
identity
function is a generic function that accepts a parameter of typeT
and returns a value of the same type. - We can call
identity
with 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.
function loggingIdentity<T extends { length: number }>(arg: T): T {
console.log(arg.length);
return arg;
}
loggingIdentity("Hello, World!"); // 13
loggingIdentity([1, 2, 3, 4, 5]); // 5
loggingIdentity({ length: 10, value: "foo" }); // 10
// loggingIdentity(42); // Error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
In this example:
- The
loggingIdentity
function is constrained to types that have alength
property. - This allows you to use the function with strings, arrays, or any other type that has a
length
property.
Generic Functions with Multiple Type Parameters
You can define generic functions with multiple type parameters to handle more complex scenarios.
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ name: "Alice" }, { age: 30 });
console.log(merged); // { name: "Alice", age: 30 }
In this example:
- The
merge
function takes two generic type parameters,T
andU
. - It returns a new object that is the intersection of
T
andU
.
Generic Classes
A generic class allows you to create a class that can work with different data types.
class Box<T> {
contents: T;
constructor(contents: T) {
this.contents = contents;
}
getContents(): T {
return this.contents;
}
}
const numberBox = new Box<number>(123);
const stringBox = new Box<string>("Hello, World!");
console.log(numberBox.getContents()); // 123
console.log(stringBox.getContents()); // Hello, World!
In this example:
- The
Box
class has a generic type parameterT
. - The
contents
property and thegetContents
method both use the typeT
.
Generic Classes with Constraints
You can also use constraints with generic classes to restrict the types that can be used.
interface Identifiable {
id: number;
}
class Repository<T extends Identifiable> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
}
interface User extends Identifiable {
name: string;
}
const userRepository = new Repository<User>();
userRepository.add({ id: 1, name: "Alice" });
userRepository.add({ id: 2, name: "Bob" });
const user = userRepository.getById(1);
console.log(user); // { id: 1, name: "Alice" }
In this example:
- The
Repository
class is constrained to types that extend theIdentifiable
interface. - The
add
method adds an item to the repository. - The
getById
method retrieves an item by its ID.
Generic Classes with Multiple Type Parameters
You can also define generic classes with multiple type parameters.
class Pair<T, U> {
constructor(public first: T, public second: U) {}
getFirst(): T {
return this.first;
}
getSecond(): U {
return this.second;
}
}
const pair = new Pair<string, number>("Hello", 42);
console.log(pair.getFirst()); // Hello
console.log(pair.getSecond()); // 42
In this example:
- The
Pair
class has two generic type parameters,T
andU
. - The class has two properties,
first
andsecond
, which are of typesT
andU
, respectively.
Generic Interfaces
Generic interfaces in TypeScript allow you to create flexible and reusable components that can work with different data types.
interface Container<T> {
value: T;
}
const stringContainer: Container<string> = { value: "Hello, World!" };
const numberContainer: Container<number> = { value: 42 };
console.log(stringContainer.value); // Hello, World!
console.log(numberContainer.value); // 42
In this example:
- The
Container
interface has a generic type parameterT
. - The
value
property 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.
interface Pair<T, U> {
first: T;
second: U;
}
const stringNumberPair: Pair<string, number> = { first: "One", second: 1 };
const booleanStringPair: Pair<boolean, string> = { first: true, second: "True" };
console.log(stringNumberPair); // { first: "One", second: 1 }
console.log(booleanStringPair); // { first: true, second: "True" }
In this example:
- The
Pair
interface has two generic type parameters,T
andU
. - 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.
interface Transformer<T, U> {
(input: T): U;
}
const stringToNumber: Transformer<string, number> = (input) => parseInt(input, 10);
const numberToString: Transformer<number, string> = (input) => input.toString();
console.log(stringToNumber("123")); // 123
console.log(numberToString(456)); // "456"
In this example:
- The
Transformer
interface defines a function type that transforms a value of typeT
to 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.
interface Repository<T> {
add(item: T): void;
getAll(): T[];
}
class MemoryRepository<T> implements Repository<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return this.items;
}
}
interface User {
id: number;
name: string;
}
const userRepository: Repository<User> = new MemoryRepository<User>();
userRepository.add({ id: 1, name: "Alice" });
userRepository.add({ id: 2, name: "Bob" });
console.log(userRepository.getAll());
// [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
In this example:
- The
Repository
interface defines methods for adding and retrieving items. - The
MemoryRepository
class implements theRepository
interface. - 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
.
interface Identifiable {
id: number;
}
interface Repository<T extends Identifiable> {
add(item: T): void;
getAll(): T[];
}
class MemoryRepository<T extends Identifiable> implements Repository<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return this.items;
}
}
interface User extends Identifiable {
name: string;
}
const userRepository: Repository<User> = new MemoryRepository<User>();
userRepository.add({ id: 1, name: "Alice" });
userRepository.add({ id: 2, name: "Bob" });
console.log(userRepository.getAll());
// [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
In this example:
- The
Identifiable
interface defines anid
property. - The
Repository
andMemoryRepository
interfaces useT extends Identifiable
to enforce that the generic typeT
must have anid
property.
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
abstract class Animal {
abstract makeSound(): void;
}
class Dog extends Animal {
makeSound(): void {
console.log("Woof!");
}
}
class Cat extends Animal {
makeSound(): void {
console.log("Meow!");
}
}
function createAnimalSound(animal: Animal): void {
animal.makeSound();
}
const dog = new Dog();
const cat = new Cat();
createAnimalSound(dog); // Woof!
createAnimalSound(cat); // Meow!
In this example:
Animal
is an abstract class with an abstract methodmakeSound
.Dog
andCat
extendAnimal
and implementmakeSound
.- The
createAnimalSound
function accepts anyAnimal
type and calls themakeSound
method, 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.
function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: any, b: any): any {
return a + b;
}
console.log(add(1, 2)); // 3
console.log(add("Hello, ", "World!")); // Hello, World!
In this example:
- The
add
function has two overloads: one for adding numbers and one for concatenating strings. - The implementation combines both cases using
any
type.
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
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = Partial<User>;
type ReadonlyUser = Readonly<User>;
type UserNameAndEmail = Pick<User, "name" | "email">;
const user: PartialUser = { id: 1 };
const readonlyUser: ReadonlyUser = { id: 1, name: "John", email: "john@example.com" };
const userNameAndEmail: UserNameAndEmail = { name: "John", email: "john@example.com" };
console.log(user); // { id: 1 }
console.log(readonlyUser); // { id: 1, name: "John", email: "john@example.com" }
console.log(userNameAndEmail); // { name: "John", email: "john@example.com" }
Utility Types Overview
- Partial
: Makes all properties in T
optional. - Readonly
: Makes all properties in T
read-only. - Pick<T, K>: Creates a type by picking a set of properties
K
fromT
.
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.
function isString(value: any): value is string {
return typeof value === "string";
}
function printValue(value: number | string): void {
if (isString(value)) {
console.log(`String value: ${value}`);
} else {
console.log(`Number value: ${value}`);
}
}
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.