javascriptroom guide

TypeScript Advanced Types: Unions, Intersections, and More

TypeScript has revolutionized JavaScript development by adding a static type system, enabling earlier error detection, better tooling, and more maintainable code. While basic types like `string`, `number`, and `boolean` handle simple scenarios, real-world applications demand more flexibility. Enter **advanced types**—powerful constructs that let you compose, transform, and narrow types to model complex data and logic. In this blog, we’ll dive deep into TypeScript’s most essential advanced types: **Unions**, **Intersections**, **Type Guards**, **Conditional Types**, **Mapped Types**, and built-in **Utility Types**. By the end, you’ll wield these tools to write type-safe, expressive code that scales with your project’s needs.

Table of Contents

1. Union Types

1.1 Definition & Syntax

A Union Type represents a value that can be one of several types. It’s defined using the | (pipe) operator between types.

Syntax:

type UnionType = Type1 | Type2 | Type3;

For example, a variable that can be a string or number:

type StringOrNumber = string | number;
let value: StringOrNumber;
value = "hello"; // ✅ Valid
value = 42;      // ✅ Valid
value = true;    // ❌ Error: Type 'boolean' is not assignable to 'StringOrNumber'

1.2 Use Cases

Unions shine when a value might come in multiple forms. Common scenarios include:

Example 1: Function Parameters with Multiple Types

A function that formats a value as a string, whether it’s a Date or a raw string:

type FormatInput = string | Date;

function formatDate(input: FormatInput): string {
  if (typeof input === "string") {
    return new Date(input).toISOString(); // Handle string input
  } else {
    return input.toISOString(); // Handle Date input
  }
}

console.log(formatDate("2024-01-01")); // "2024-01-01T00:00:00.000Z"
console.log(formatDate(new Date()));   // Current date as ISO string

Example 2: Discriminated Unions (Tagged Unions)

For complex unions, add a “discriminant” property (e.g., type: "user" or type: "admin") to simplify narrowing:

type User = { type: "user"; name: string };
type Admin = { type: "admin"; name: string; permissions: string[] };
type Person = User | Admin;

function greet(person: Person): string {
  if (person.type === "user") {
    return `Hello, ${person.name}!`; // Narrowed to User
  } else {
    return `Hello, Admin ${person.name}! Permissions: ${person.permissions.join(", ")}`; // Narrowed to Admin
  }
}

1.3 Narrowing Union Types with Type Guards

Unions are flexible, but TypeScript needs help “narrowing” the type to a specific member (e.g., distinguishing string from number in string | number). Type Guards are functions or checks that tell TypeScript the specific type of a value.

Common Type Guards:

  • typeof Guard: For primitives (string, number, boolean, symbol).

    function logValue(value: string | number): void {
      if (typeof value === "string") {
        console.log("String length:", value.length); // TypeScript knows value is string here
      } else {
        console.log("Number doubled:", value * 2); // TypeScript knows value is number here
      }
    }
  • instanceof Guard: For class instances.

    class Dog { bark() {} }
    class Cat { meow() {} }
    type Pet = Dog | Cat;
    
    function makeSound(pet: Pet): void {
      if (pet instanceof Dog) {
        pet.bark(); // Narrowed to Dog
      } else {
        pet.meow(); // Narrowed to Cat
      }
    }
  • User-Defined Type Guards: For custom types (e.g., objects). Use the is keyword to return a type predicate.

    type Fish = { swim: () => void };
    type Bird = { fly: () => void };
    
    // Type guard: Returns true if 'pet' is a Fish
    function isFish(pet: Fish | Bird): pet is Fish {
      return (pet as Fish).swim !== undefined; // Check for Fish-specific property
    }
    
    function move(pet: Fish | Bird): void {
      if (isFish(pet)) {
        pet.swim(); // Narrowed to Fish
      } else {
        pet.fly(); // Narrowed to Bird
      }
    }

2. Intersection Types

2.1 Definition & Syntax

An Intersection Type combines multiple types into one, requiring a value to have all properties/members of each type. It’s defined using the & (ampersand) operator.

Syntax:

type IntersectionType = Type1 & Type2 & Type3;

For example, merging User and Contact types:

type User = { id: number; name: string };
type Contact = { email: string; phone: string };
type UserWithContact = User & Contact; // { id: number; name: string; email: string; phone: string }

const user: UserWithContact = {
  id: 1,
  name: "Alice",
  email: "[email protected]",
  phone: "555-1234"
}; // ✅ Valid (has all properties of User and Contact)

2.2 Use Cases

Intersections are ideal for:

  • Merging interfaces or types (e.g., combining user data with metadata).
  • Adding properties to existing types without modifying them.

Example: Merging Configuration Types

type BaseConfig = { apiUrl: string; timeout: number };
type AuthConfig = { apiKey: string };
type FullConfig = BaseConfig & AuthConfig; // Requires all properties from both

const config: FullConfig = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  apiKey: "secret-key"
};

2.3 Pitfalls: Conflicting Properties

If two types in an intersection have a property with conflicting types, the result is never (TypeScript can’t resolve the conflict):

type A = { id: string };
type B = { id: number };
type C = A & B; // { id: never } (string & number is never)

const c: C = { id: "123" }; // ❌ Error: Type 'string' is not assignable to 'never'

3. Conditional Types

Conditional Types let you define types based on a condition (T extends U ? X : Y). They’re like “type-level if statements” and are often used to create reusable, dynamic types.

3.1 Syntax & Basic Behavior

Syntax:

type ConditionalType<T> = T extends U ? X : Y;
  • If T is assignable to U, the type is X; otherwise, it’s Y.

Example: Simple Conditional Type

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>; // "yes" (string extends string)
type B = IsString<number>; // "no" (number does not extend string)
type C = IsString<string | number>; // "yes" | "no" (distributive over unions)

3.2 Advanced: infer Keyword

The infer keyword lets you “extract” a type from another type within a conditional. It’s powerful for tasks like extracting return types or parameter types of functions.

Example 1: Extract Return Type of a Function

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Func = () => string;
type FuncReturnType = ReturnType<Func>; // string (inferred R is string)

type AsyncFunc = () => Promise<number>;
type AsyncReturnType = ReturnType<AsyncFunc>; // Promise<number>

Example 2: Extract Array Element Type

type ArrayElement<T> = T extends (infer E)[] ? E : T;

type Numbers = number[];
type NumberElement = ArrayElement<Numbers>; // number (inferred E is number)

type NotAnArray = string;
type NotAnArrayElement = ArrayElement<NotAnArray>; // string (falls back to T)

4. Mapped Types

Mapped Types let you iterate over the properties of an existing type (T) and transform each property to create a new type. They’re used to create utilities like Readonly<T> or Partial<T>.

4.1 Definition & Syntax

Syntax:

type MappedType<T> = {
  [P in keyof T]: Transform<T[P]>; // Iterate over each property P in T
};
  • keyof T gets all property keys of T (e.g., keyof { a: number; b: string } is "a" | "b").
  • in iterates over each key in keyof T.

4.2 Practical Examples

Example 1: Make All Properties Readonly

Recreating TypeScript’s built-in Readonly<T>:

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P]; // Add 'readonly' modifier to each property
};

type User = { name: string; age: number };
type ReadonlyUser = MyReadonly<User>; // { readonly name: string; readonly age: number }

const user: ReadonlyUser = { name: "Bob", age: 30 };
user.name = "Alice"; // ❌ Error: Cannot assign to 'name' because it is a read-only property

Example 2: Make Properties Optional

Recreating Partial<T> (covered in Utility Types below):

type MyPartial<T> = {
  [P in keyof T]?: T[P]; // Add 'optional' modifier (?) to each property
};

type User = { name: string; age: number };
type PartialUser = MyPartial<User>; // { name?: string; age?: number }

const partialUser: PartialUser = { name: "Charlie" }; // ✅ Valid (age is optional)

Example 3: Transform Property Types

Map each property to a function that returns the original type:

type ToFunction<T> = {
  [P in keyof T]: () => T[P]; // Each property becomes a function returning T[P]
};

type Data = { id: number; name: string };
type DataGetters = ToFunction<Data>; // { id: () => number; name: () => string }

const getters: DataGetters = {
  id: () => 1,
  name: () => "Alice"
};

5. Built-in Utility Types

TypeScript provides a set of built-in Utility Types that leverage advanced types (unions, intersections, mapped types, conditionals) to solve common problems. Here are the most useful ones:

5.1 Partial<T> & Required<T>

  • Partial<T>: Makes all properties of T optional.

    interface Todo { title: string; completed: boolean; }
    type PartialTodo = Partial<Todo>; // { title?: string; completed?: boolean }
    
    // Use case: Updating an object with partial data
    function updateTodo(todo: Todo, changes: Partial<Todo>): Todo {
      return { ...todo, ...changes };
    }
    
    const todo: Todo = { title: "Learn TS", completed: false };
    const updated = updateTodo(todo, { completed: true }); // ✅ { title: "Learn TS", completed: true }
  • Required<T>: The opposite of Partial<T>—makes all properties required.

    type OptionalTodo = Partial<Todo>;
    type RequiredTodo = Required<OptionalTodo>; // { title: string; completed: boolean }

5.2 Readonly<T>

Makes all properties of T readonly.

type ReadonlyTodo = Readonly<Todo>; // { readonly title: string; readonly completed: boolean }

const todo: ReadonlyTodo = { title: "Read", completed: false };
todo.title = "Write"; // ❌ Error: Readonly property

5.3 Pick<T, K> & Omit<T, K>

  • Pick<T, K>: Selects a subset of properties K from T.

    type TodoTitle = Pick<Todo, "title">; // { title: string }
  • Omit<T, K>: Removes properties K from T (opposite of Pick).

    type TodoWithoutTitle = Omit<Todo, "title">; // { completed: boolean }

5.4 Exclude<T, U> & Extract<T, U>

These work with union types:

  • Exclude<T, U>: Removes types from T that are assignable to U.

    type T = "a" | "b" | "c";
    type U = "a" | "d";
    type Excluded = Exclude<T, U>; // "b" | "c" (removes "a" because "a" extends U)
  • Extract<T, U>: Keeps types from T that are assignable to U.

    type Extracted = Extract<T, U>; // "a" (keeps "a" because "a" extends U))

Conclusion

Advanced types in TypeScript—Unions, Intersections, Conditional Types, Mapped Types, and Utility Types—unlock powerful ways to model complex data and logic. By mastering these tools, you’ll write more expressive, type-safe code that catches errors early and scales with your application’s needs.

Start small: use unions for flexible parameters, intersections to merge types, and utility types like Partial or Pick to simplify common tasks. As you grow, experiment with conditional and mapped types to build custom type utilities tailored to your project.

References