TypeScript Advanced Types: Mastering Union, Intersection, and Conditional Types

November 2, 2024 (2w ago)

TypeScript Advanced Types: Mastering Union, Intersection, and Conditional Types

As you progress in TypeScript, understanding advanced types such as union types, intersection types, and conditional types can enhance the flexibility, readability, and type safety of your code. These types allow for precise type definitions, dynamic type combinations, and even conditional logic within types, making them invaluable tools in TypeScript development. In this guide, we’ll dive into these advanced types with practical examples and explore how they can optimize your TypeScript code.


Union Types in TypeScript

A union type allows a variable to hold multiple types, making it useful when a value could be one of several specified types. Unions are created using the | (pipe) symbol.

Defining a Union Type

Here’s an example of a union type for a variable that could be either a string or number:

function printId(id: string | number): void {
  console.log("ID:", id);
}
 
printId(101);         // Output: "ID: 101"
printId("ABC123");    // Output: "ID: ABC123"

With a union type, the printId function accepts either a string or number. This flexibility can be useful for handling data with varying types, such as IDs or input values.

Using Type Guards with Unions

Type guards allow you to safely access properties or methods by narrowing down the type within a union.

function display(value: string | number) {
  if (typeof value === "string") {
    console.log("String:", value.toUpperCase());
  } else {
    console.log("Number:", value.toFixed(2));
  }
}
 
display("hello");  // Output: "String: HELLO"
display(3.14);     // Output: "Number: 3.14"

The typeof operator checks the type at runtime, letting you safely handle each specific type within the union.

Union Types with Custom Types

Union types can combine custom types, providing flexibility for more complex data structures.

type Dog = { breed: string; bark: () => void };
type Cat = { breed: string; meow: () => void };
 
function makeSound(animal: Dog | Cat) {
  if ("bark" in animal) {
    animal.bark();
  } else {
    animal.meow();
  }
}

In this example, makeSound checks for the bark method to differentiate between Dog and Cat types.


Intersection Types in TypeScript

An intersection type combines multiple types into one, making it possible for an object to have all properties and methods from each type in the intersection. This is useful for creating composite types, where you need an object with characteristics from multiple types.

Defining an Intersection Type

Intersection types use the & symbol to combine types.

type Employee = { employeeId: number; name: string };
type Manager = { department: string };
 
type ManagerEmployee = Employee & Manager;
 
const manager: ManagerEmployee = {
  employeeId: 123,
  name: "Alice",
  department: "HR"
};
 
console.log(manager);

The ManagerEmployee type requires properties from both Employee and Manager, making it suitable for representing roles with overlapping characteristics.

Handling Conflicts in Intersection Types

If two types in an intersection share a property with incompatible types, TypeScript will throw an error. To avoid this, ensure shared properties have compatible types.

type Car = { name: string; wheels: number };
type Boat = { name: string; capacity: number };
 
type AmphibiousVehicle = Car & Boat;
 
// Correct Usage
const vehicle: AmphibiousVehicle = {
  name: "Amphicar",
  wheels: 4,
  capacity: 2
};
 
console.log(vehicle);

In this case, AmphibiousVehicle combines the properties of Car and Boat, making it suitable for a vehicle that can operate on both land and water.


Conditional Types in TypeScript

Conditional types allow types to be defined based on conditions, similar to an if-else statement. This provides powerful dynamic typing capabilities, enabling you to create types that depend on other types.

Basic Conditional Type Syntax

A conditional type has the syntax T extends U ? X : Y, where:

type IsString<T> = T extends string ? "It’s a string" : "Not a string";
 
type Test1 = IsString<string>;  // "It’s a string"
type Test2 = IsString<number>;  // "Not a string"

Here, IsString<string> evaluates to "It’s a string", while IsString<number> evaluates to "Not a string".

Extracting Properties with Conditional Types

Conditional types are especially useful for working with specific properties of complex types.

type ExtractStringProperties<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
 
interface User {
  name: string;
  age: number;
  email: string;
}
 
type StringProps = ExtractStringProperties<User>; // "name" | "email"

ExtractStringProperties creates a type with only the properties of User that are string, allowing selective access to specific properties.

Conditional Types with Generic Types

Conditional types work well with generic types, making them dynamic based on input types.

type ArrayElementType<T> = T extends (infer U)[] ? U : T;
 
type StringArray = ArrayElementType<string[]>; // string
type NumberArray = ArrayElementType<number[]>; // number
type NonArray = ArrayElementType<boolean>;     // boolean

In this example, ArrayElementType extracts the type of elements in an array or leaves the type unchanged if it’s not an array.


Practical Examples of Advanced Types

Advanced types can be combined and used in real-world scenarios for type safety and dynamic flexibility.

1. Handling API Response Types with Unions and Conditionals

type SuccessResponse = { status: "success"; data: string };
type ErrorResponse = { status: "error"; error: string };
 
type ApiResponse<T> = T extends "success" ? SuccessResponse : ErrorResponse;
 
function fetchApi<T extends "success" | "error">(status: T): ApiResponse<T> {
  if (status === "success") {
    return { status: "success", data: "Data loaded" } as ApiResponse<T>;
  } else {
    return { status: "error", error: "Failed to load data" } as ApiResponse<T>;
  }
}
 
const successResponse = fetchApi("success");
const errorResponse = fetchApi("error");

In this example, fetchApi returns different types based on the status passed in, leveraging conditional types to create a type-safe API response handler.

2. Combining Types in Form Validation

type RequiredField = { required: boolean };
type EmailField = { format: "email" };
type TextField = { minLength: number; maxLength: number };
 
type FormField = RequiredField & (EmailField | TextField);
 
const emailField: FormField = {
  required: true,
  format: "email",
};
 
const textField: FormField = {
  required: true,
  minLength: 5,
  maxLength: 50,
};

Here, FormField uses intersections and unions to represent fields that could be either email or text, but must include the required property, creating flexible validation rules.

3. Creating a Function Overload with Conditional Types

Conditional types can be used to create type-safe overloads for functions.

type Overload<T> = T extends string
  ? string
  : T extends number
  ? number
  : boolean;
 
function parseInput<T extends string | number>(input: T): Overload<T> {
  if (typeof input === "string") {
    return input.toUpperCase() as Overload<T>;
  } else if (typeof input === "number") {
    return (input * 2) as Overload<T>;
  }
  return false as Overload<T>;
}
 
console.log(parseInput("hello")); // Output: "HELLO"
console.log(parseInput(5));       // Output: 10

This function uses a conditional type to determine its return type based on the type of input, creating a flexible overload pattern.


Conclusion

TypeScript’s advanced types, including union, intersection, and conditional types, allow you to define complex and dynamic type structures that enhance the type safety and flexibility of your code. By mastering these types, you can write more adaptable TypeScript code that accurately represents data models, enhances code readability, and minimizes runtime errors.

Use these advanced types in your TypeScript projects to unlock new levels of type precision, creating robust applications that are both maintainable and scalable.