Union Types

In TypeScript, variables can be typed with varying levels of specificity. For instance, if we want a variable to only accept string values, we can define its type as string. This makes the type very specific since TypeScript will only allow that variable to hold a string.

At the other extreme, we can declare a variable with the type any, which is completely unspecific. This means the variable can hold any value—string, number, object, etc.—without TypeScript raising an error.

While both extremes are useful in certain situations, there are cases where we need something in between. Imagine you're working on a program that handles employee IDs, which can either be a string or a number. If we were to use the any type, it wouldn't be ideal, because it's too loose and could lead to unintended values:

let employeeID: any;
console.log(`Employee ID is: ${employeeID}`);

In this case, we risk passing a completely unrelated value (like an object or boolean) to employeeID, which could cause problems in the program.

Defining Union Types

Let's say you're working with an employee ID system where IDs can either be a number or a string. Instead of using the any type, which is too permissive, you can define a union type like this:

let employeeID: string | number;

// Assigning a number
employeeID = 123;

// Assigning a string
employeeID = 'EMP001';

console.log(`Employee ID: ${employeeID}`);

In this example, string | number forms a union, allowing employeeID to hold either a string or a number. This gives us flexibility without losing type control, making it more specific than using any.

Union Types in Functions

Union types are also very handy when dealing with function parameters, where a function might need to handle inputs of different types. For example, let’s create a function that sets the width of an element. The width could be either a string (such as '100px') or a number (like 100):

function setElementWidth(width: string | number) {
  return { width };
}

In this function, setElementWidth can accept both a string and a number, allowing for either unit-based values or plain numbers.

const style1 = setElementWidth('200px');  // { width: '200px' }
const style2 = setElementWidth(250);      // { width: 250 }

Benefits of Union Types

  • Controlled Flexibility: Union types let you handle multiple types while still avoiding the potential chaos of using any.
  • Type Safety: TypeScript ensures that the values passed in conform to the types specified in the union, helping catch errors early.
  • Cleaner Code: Union types make the code more readable by clearly defining the acceptable types, improving maintainability.

By incorporating union types into your code, you allow for flexibility in variable and function parameters while still leveraging TypeScript's strong type system. This leads to cleaner, more reliable code.

Union Types and Type Narrowing

When working with union types, you allow variables or function parameters to hold multiple types of values. However, this flexibility also requires handling these types carefully in your code. Type narrowing allows TypeScript to refine a union to a more specific type based on runtime checks, letting you apply type-specific logic without running into errors.

Type Narrowing with Union Types

Let's start with a function that takes in a padding parameter, which could be either a string or a number:

function setPadding(padding: string | number) {
  // Implementation here
}

In this case, padding can either be a string like '15px' or a number like 15. Since the logic for handling a string might differ from handling a number, you'll need to apply different operations depending on the type of the padding parameter.

Using typeof for Type Guards

To handle these cases, you can use the typeof operator as a type guard. This will check the type of padding and allow you to apply specific logic for strings or numbers:

function setPadding(padding: string | number) {
  if (typeof padding === 'string') {
    // If it's a string, manipulate the string value
    return `Padding is set to ${padding}`;
  } else {
    // If it's a number, perform a numeric operation
    return `Padding is set to ${padding * 2}px`;
  }
}

Here, if padding is a string, TypeScript allows string-specific operations like template literals. If it's a number, you can perform number-specific operations like multiplication.

Example with Type-Specific Logic

Let's consider a more practical example where you want to handle margins that could be defined either as a percentage (string) or a direct pixel value (number):

function applyMargin(margin: string | number) {
  if (typeof margin === 'string') {
    // If the margin is a percentage, ensure it's valid
    return margin.includes('%') ? margin : `${margin}%`;
  } else {
    // If the margin is a number, convert it to pixels
    return `${margin}px`;
  }
}

console.log(applyMargin('50%')); // Output: '50%'
console.log(applyMargin('20'));   // Output: '20%'
console.log(applyMargin(30));     // Output: '30px'

In this example, we use a type guard to check if margin is a string and ensure it's correctly formatted with a percentage sign. For number values, we convert the margin into pixel units. This type-specific logic is achieved by narrowing down the type using typeof.

Why Type Narrowing Is Important

  • Type Safety: Type narrowing ensures that you're applying the correct methods or operations to variables of specific types, helping avoid potential runtime errors.
  • Cleaner Code: By narrowing types, you write more focused, readable code without unnecessary checks.
  • Error Prevention: Without narrowing, calling methods or performing operations that don't apply to certain types (like calling string methods on a number) would result in errors. TypeScript helps catch these issues early.

Another Example: Handling Different Input Types

Here's a final example showing how you might handle different user inputs, such as a discount that could be either a percentage (string) or a fixed amount (number):

function applyDiscount(discount: string | number) {
  if (typeof discount === 'string') {
    // Assume the string is a percentage and remove the '%' symbol
    const value = parseFloat(discount);
    return value / 100; // Convert to a decimal
  } else {
    // Treat numbers as fixed amounts
    return discount;
  }
}

console.log(applyDiscount('20%')); // Output: 0.2
console.log(applyDiscount(50));    // Output: 50

In this scenario, the function handles both string percentages (like '20%') and fixed numbers (like 50). We perform type-specific logic using type narrowing, allowing TypeScript to infer the correct types during execution.

Type narrowing is essential for safely working with union types, ensuring that your code is both flexible and reliable.

Union Types and Inferred Return Types

One of the great features of TypeScript is its ability to infer types automatically, reducing the need for manual type annotations. This is especially useful when a function can return multiple types, as TypeScript will infer the return type as a union of those types.

Example: Inferring Union Return Types

Let's take an example where we call a function fetchUserData(), which might either succeed or fail:

function getUserData() {
  try {
    return fetchUserData();  // Returns a User object on success
  } catch (error) {
    return `Failed to fetch data: ${error.message}`;  // Returns a string on failure
  }
}

Here, if the fetchUserData() call is successful, the function returns a User object. If it fails, it returns an error message as a string. Since there are two possible return types (User or string), TypeScript automatically infers the return type as User | string.

No Need for Manual Type Declaration

Because TypeScript can infer that the return type is a union (User | string), you don't need to manually declare it. But if you wanted to, it would look like this:

function getUserData(): User | string {
  try {
    return fetchUserData();
  } catch (error) {
    return `Failed to fetch data: ${error.message}`;
  }
}

In this case, specifying the return type manually doesn't offer any additional benefit since TypeScript already infers it. This helps keep your code cleaner and easier to maintain.

Handling Inferred Union Types

Once a function returns a union type, you need to ensure you handle each possible type correctly. If you try to use a method exclusive to one type without checking the type first, TypeScript will throw an error. For instance:

const result = getUserData();
console.log(result.toUpperCase());  // Error: Property 'toUpperCase' does not exist on type 'User | string'

To handle this correctly, we need to narrow the type before accessing any properties or methods specific to one of the types in the union.

Using Type Narrowing with Inferred Union Types

You can use type narrowing to safely handle different types returned from a function. Here's an example that checks whether the result is a string or a User object:

function displayUserData() {
  const result = getUserData();

  if (typeof result === 'string') {
    console.log(result.toUpperCase());  // Safe to call string methods
  } else {
    console.log(`User Name: ${result.name}`);  // Safe to access properties of a User object
  }
}

In this example, TypeScript uses the typeof check to narrow the type inside each block. If the result is a string, we safely use string methods. If it's a User object, we can access its properties without TypeScript throwing any errors.

Why Inferred Return Types Matter

  • Less Boilerplate: You don't have to manually annotate return types when TypeScript can infer them, making your code simpler and more readable.
  • Type Safety: Even with inferred union types, TypeScript still enforces type safety. It ensures that you handle each possible type appropriately, preventing common runtime errors.
  • Flexibility: Union types provide flexibility while still maintaining type checking, allowing you to handle functions that may return multiple types in a clean and robust way.

Final Example: Combining Inference and Type Guards

Consider a scenario where we fetch either a product's data or an error message. We can combine inferred union types and type narrowing for a robust solution:

function fetchProductData() {
  try {
    return { name: 'Laptop', price: 999 };  // Returns a Product object
  } catch (error) {
    return `Error: ${error.message}`;  // Returns a string if there's an error
  }
}

function showProduct() {
  const result = fetchProductData();

  if (typeof result === 'string') {
    console.error(result);  // Handle the error message
  } else {
    console.log(`Product: ${result.name}, Price: $${result.price}`);  // Handle the product object
  }
}

By using inferred union return types and type guards, we can handle multiple types returned from a function safely and efficiently.

Conclusion

Inferred union return types in TypeScript streamline your code by removing the need for explicit type annotations while maintaining strict type safety. Type narrowing lets you confidently handle each type in the union, ensuring your program works correctly without type mismatches. This combination of flexibility and type safety is one of TypeScript's key strengths.

Union Types with Arrays

Union types in TypeScript become even more powerful when combined with arrays. They allow us to create arrays that can store multiple types of values, providing flexibility while maintaining type safety.

Example: Using Union Types for Mixed Arrays

Let's say we want to store timestamps in different formats—some as numbers (like Unix timestamps) and others as human-readable strings. To handle this, we can use a union type for an array that holds both numbers and strings.

const numericTimestamp = Date.now(); // returns a number (Unix timestamp)
const stringTimestamp = new Date().toLocaleString(); // returns a string

const timestamps: (number | string)[] = [numericTimestamp, stringTimestamp];

In this example, the timestamps array can store both numbers and strings. We can add either type to the array, but TypeScript will enforce that only number or string values are allowed.

If you try to push a value of a different type, like a boolean, TypeScript will throw an error:

timestamps.push(true);  // Error: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.

This ensures that our array remains consistent with the types we’ve defined in the union.

Importance of Parentheses in Union Arrays

When defining union types for arrays, parentheses play a crucial role in determining the correct structure. Without parentheses, the meaning of the type can change.

let invalidArray: string | number[];  // This means either a string OR an array of numbers

In the above case, invalidArray can either be a single string, or it can be an array of numbers, but not an array that contains both strings and numbers.

To create an array that holds a mix of string and number values, you need parentheses around the union:

let validArray: (string | number)[];  // This means an array that can contain both strings and numbers

With the parentheses, the type is properly interpreted as an array where each element can be either a string or a number.

Practical Example: Displaying Event Times

Let's consider a practical use case where we track event times in both Unix timestamps (numbers) and formatted date strings. We want to create a function that processes this data and outputs it consistently.

function displayEventTimes(eventTimes: (string | number)[]) {
  eventTimes.forEach(time => {
    if (typeof time === 'number') {
      console.log(`Event at timestamp: ${time}`);
    } else {
      console.log(`Event occurred at: ${time}`);
    }
  });
}

const eventTimesList: (string | number)[] = [Date.now(), "2024-09-13 10:30:00"];
displayEventTimes(eventTimesList);

In this example, displayEventTimes accepts an array of mixed strings and numbers. Inside the function, a type guard (typeof time === 'number') is used to determine whether each element is a number or a string, and the appropriate output is logged accordingly.

Key Takeaways

  • Union Types with Arrays: You can create arrays that accept multiple types of values by using union types. Just wrap the union in parentheses before applying the array notation [].

  • Parentheses Matter: Without parentheses, the meaning of the union type can change. Always use (type1 | type2)[] for mixed arrays, not type1 | type2[].

  • Type Safety: TypeScript ensures that only the specified types in the union can be added to the array, helping to prevent runtime errors.

This combination of union types and arrays offers flexibility in handling different data formats while keeping your code safe and predictable.

Common Key-Value Pairs

In TypeScript, when using union types, you can only access properties and methods that are common across all types in the union. This ensures that code remains type-safe, preventing you from calling methods or accessing properties that may not exist on every type in the union.

Example 1: Shared Methods Between Primitives

Let's consider a scenario where we have a variable that could either be a boolean or a number:

const status: boolean | number = true;

console.log(status.toString());  // No error: Both boolean and number have toString()
console.log(status.toFixed(2));  // Error: Only number has toFixed()

In this example, since both boolean and number have the toString() method, calling toString() works without issue. However, toFixed() is a method specific to number, so TypeScript flags it as an error when the variable status could also be a boolean.

Example 2: Shared Properties in Custom Types

Now let's apply the same concept to objects. Consider two custom types, Car and Bike:

type Car = {
  wheels: number;
  hasAirbags: boolean;
};

type Bike = {
  wheels: number;
  hasPedals: boolean;
};

const vehicle: Car | Bike = { wheels: 2, hasPedals: true };

console.log(vehicle.wheels);      // No error: Both Car and Bike have 'wheels'
console.log(vehicle.hasAirbags);  // Error: Only Car has 'hasAirbags'

In this case, wheels is a shared property, so TypeScript allows it to be accessed without issue. However, hasAirbags exists only on the Car type, so accessing it on vehicle will result in an error.

Example 3: Using Type Guards for Specific Logic

When you need to access properties that are unique to one type in the union, type guards can help by narrowing down the type at runtime. Here's how you can use the in operator to differentiate between types:

type Dog = {
  breed: string;
  barkVolume: number;
};

type Cat = {
  breed: string;
  purringFrequency: number;
};

function describePet(pet: Dog | Cat) {
  if ('barkVolume' in pet) {
    // TypeScript knows pet is a Dog here
    console.log(`This dog is a ${pet.breed} and barks at volume ${pet.barkVolume}.`);
  } else {
    // TypeScript knows pet is a Cat here
    console.log(`This cat is a ${pet.breed} and purrs at a frequency of ${pet.purringFrequency}.`);
  }
}

const myPet: Dog | Cat = { breed: "Siamese", purringFrequency: 200 };
describePet(myPet);  // Output: This cat is a Siamese and purrs at a frequency of 200.

In the above example, we use the in operator to check if barkVolume exists in the pet object. If it does, TypeScript treats pet as a Dog, allowing access to barkVolume. Otherwise, it treats pet as a Cat, allowing access to purringFrequency.

Key Points Recap

  • Accessing Common Members: TypeScript only allows access to properties or methods that are shared among all members of a union. This prevents you from calling methods or accessing properties that don't exist on some types.

  • Type Guards: You can narrow the type using type guards such as typeof, instanceof, or the in operator. This lets TypeScript infer the exact type at runtime, allowing you to access type-specific properties or methods.

Using Literal Types

In TypeScript, literal types combined with union types allow you to create a set of predefined values, ensuring that only valid values are passed into functions or variables. This is incredibly useful when managing specific states in your application, offering a level of safety and predictability to your code.

Example: Managing Device Status

Consider a scenario where you're building a system to manage different statuses of a device (like a smartphone). The device can only be in a limited set of states—such as 'online', 'offline', or 'maintenance'. Using literal types with a union can help ensure that only these predefined statuses are used:

type DeviceStatus = 'online' | 'offline' | 'maintenance';

function updateDeviceStatus(status: DeviceStatus) {
  console.log(`The device is now ${status}.`);
}

Here, DeviceStatus is a union of the literal types 'online', 'offline', and 'maintenance'. When you call the updateDeviceStatus() function, TypeScript enforces that only these specific values are accepted:

updateDeviceStatus('online');    // Valid
updateDeviceStatus('sleeping');  // Error: 'sleeping' is not assignable to type 'DeviceStatus'

This method ensures that the device can only transition to valid states, reducing the risk of logical errors in the system.

Example: Order Status in a Shopping Cart

In an e-commerce platform, an order can only have certain predefined statuses, such as 'pending', 'shipped', or 'delivered'. Literal type unions are perfect for handling such cases:

type OrderStatus = 'pending' | 'shipped' | 'delivered' | 'cancelled';

function updateOrderStatus(orderId: number, status: OrderStatus) {
  console.log(`Order ${orderId} is now ${status}.`);
}

By using a union of literal types for OrderStatus, the system ensures that only valid statuses are used when updating an order:

updateOrderStatus(123, 'shipped');   // Valid
updateOrderStatus(456, 'processing'); // Error: 'processing' is not a valid order status

This allows your program to reject any unsupported states for orders, helping you maintain better control over the state transitions.

Example: Defining User Roles in an App

Suppose you're developing an application where users can have different roles. You want to ensure that only predefined roles such as 'admin', 'editor', and 'viewer' are allowed. You can use literal type unions to enforce this:

type UserRole = 'admin' | 'editor' | 'viewer';

function assignUserRole(userId: number, role: UserRole) {
  console.log(`User ${userId} is assigned the role: ${role}.`);
}

Here, TypeScript ensures that the assignUserRole() function only accepts one of the three predefined roles:

assignUserRole(1, 'admin');    // Valid
assignUserRole(2, 'guest');    // Error: 'guest' is not assignable to type 'UserRole'

This way, your app avoids any invalid roles being assigned to users, which could otherwise lead to unexpected behaviors.

Benefits of Using Literal Types with Unions

  • Clear Intent: By explicitly defining possible values with literal types, your code becomes easier to understand and reason about.

  • Compile-Time Error Prevention: TypeScript will flag invalid values at compile time, preventing potential runtime errors and increasing the reliability of your code.

  • Safer State Management: When working with state transitions (e.g., device statuses, order processing), using literal type unions ensures that only valid state changes occur, making your system more robust.

Conclusion

Combining literal types with union types in TypeScript is a powerful way to enforce strict value constraints in your code. This technique allows you to manage distinct states efficiently, preventing errors and improving code quality. Whether you're dealing with device statuses, user roles, or order tracking, using literal type unions ensures you work within the boundaries of predefined values, helping you write safer and more maintainable code.