Ensuring Exclusive Properties with TypeScript: Unions vs. Discriminated Unions

2024-07-27

In TypeScript, interfaces define the structure of objects, but by default, all properties are optional. This can be problematic when you want to ensure that at least one of two specific properties is present in an object.

Solution: Unions and Discriminated Unions

  1. Unions:

    • Create separate interfaces for each property combination:
      interface Option1 {
        property1: string;
      }
      
      interface Option2 {
        property2: number;
      }
      
    • Combine them using a union (|):
      type MyType = Option1 | Option2;
      
  2. Discriminated Unions (Recommended):

    • Include a common property (discriminator) with different types in each interface:
      interface Option1 {
        discriminator: "option1";
        property1: string;
      }
      
      interface Option2 {
        discriminator: "option2";
        property2: number;
      }
      
    • Use this property to differentiate between options during usage:
      function handleOption(option: MyType) {
        if (option.discriminator === "option1") {
          // Use property1
        } else if (option.discriminator === "option2") {
          // Use property2
        } else {
          // Handle unexpected type (optional)
        }
      }
      

Explanation:

  • Unions allow an object to be of either type Option1 or Option2.
  • Discriminated unions add a way to identify which option is present at runtime.

Choosing the Right Approach

  • Unions are simpler but lack runtime type information.
  • Discriminated unions provide better type safety and clarity, especially for complex scenarios.

Example with Discriminated Union:

interface Login {
  discriminator: "login";
  username: string;
  password: string;
}

interface Signup {
  discriminator: "signup";
  email: string;
}

type AuthData = Login | Signup;

function handleAuth(data: AuthData) {
  if (data.discriminator === "login") {
    // Handle login credentials (username, password)
  } else if (data.discriminator === "signup") {
    // Handle signup details (email)
  }
}

const loginData: AuthData = {
  discriminator: "login",
  username: "alice",
  password: "secret",
};

handleAuth(loginData);

In this example, TypeScript ensures that loginData has either username and password (for login) or email (for signup). The discriminator property helps differentiate between the two options during usage.




interface Option1 {
  property1: string;
}

interface Option2 {
  property2: number;
}

type MyType = Option1 | Option2;

const option1: MyType = { property1: "Hello" };  // Valid: has property1
const option2: MyType = { property2: 42 };       // Valid: has property2

// This wouldn't cause a compile-time error, but could lead to runtime issues at usage:
const invalidOption: MyType = {};                 // No properties, might cause issues later

function handleOption(option: MyType) {
  // Need to check if properties exist before using them:
  if (option.property1) {
    console.log("Option 1:", option.property1);
  } else if (option.property2) {
    console.log("Option 2:", option.property2);
  } else {
    console.error("Invalid option:", option);
  }
}

handleOption(option1); // Output: Option 1: Hello
handleOption(option2); // Output: Option 2: 42
handleOption(invalidOption); // Output: Invalid option: {} (might throw an error)
interface Option1 {
  discriminator: "option1";
  property1: string;
}

interface Option2 {
  discriminator: "option2";
  property2: number;
}

type MyType = Option1 | Option2;

const option1: MyType = { discriminator: "option1", property1: "World" };
const option2: MyType = { discriminator: "option2", property2: 100 };

// Now this would cause a compile-time error:
// const invalidOption: MyType = {};  // Missing discriminator property

function handleOption(option: MyType) {
  switch (option.discriminator) {
    case "option1":
      console.log("Option 1:", option.property1);
      break;
    case "option2":
      console.log("Option 2:", option.property2);
      break;
    default:
      console.error("Unexpected option type:", option);
  }
}

handleOption(option1); // Output: Option 1: World
handleOption(option2); // Output: Option 2: 100
// handleOption(invalidOption); // Compile-time error: Type '{}' is not assignable to type 'MyType'

Key Differences:

  • Union: Simpler syntax, but requires runtime checks for property existence.
  • Discriminated Union: Provides better type safety and clarity through the discriminator property, making it easier to handle different options during usage (switch statement is a common pattern).



TypeScript's conditional types allow you to define the structure of an interface based on another type. Here's a possible approach (though slightly more complex):

type OnlyOne<T, U> = {
  [P in keyof T]: T[P] extends never ? U : never; // Ensure only one property from T is present
} & ({ [P in keyof U]: 1 }); // Marker property to enforce at least one

type Login = {
  username: string;
  password: string;
};

type Signup = {
  email: string;
};

type AuthData = OnlyOne<Login, Signup>; // Ensures either Login or Signup properties

// Now this would cause a compile-time error:
// const invalidData: AuthData = {};

const loginData: AuthData = {
  username: "user",
  password: "pass",
};

const signupData: AuthData = {
  email: "[email protected]",
};
  • OnlyOne<T, U> is a generic type that ensures only one property from either T or U can exist in the resulting type.
  • The conditional type logic achieves this by checking if a property from T extends never (meaning it's not present). If so, it allows properties from U.
  • The second part (& ({ [P in keyof U]: 1 })) adds a marker property (here, 1) to enforce that at least one property must exist.

Caveats:

  • This approach can be more complex to understand and reason about.
  • It might not be as widely used as unions or discriminated unions.

Utility Types (Libraries)

Some third-party libraries might offer utility types specifically designed for enforcing "one or the other" requirements. These can provide a more concise syntax, but rely on external dependencies.

  • For most cases: Unions or discriminated unions are the recommended approaches for their simplicity and widespread adoption.
  • For complex scenarios involving conditional logic: Conditional types could be an option, but weigh the trade-off in complexity.
  • If a specific library provides a well-maintained utility type: Consider using it for its convenience, but be mindful of dependencies.

typescript



Understanding Getters and Setters in TypeScript with Example Code

Getters and SettersIn TypeScript, getters and setters are special methods used to access or modify the values of class properties...


Taming Numbers: How to Ensure Integer Properties in TypeScript

Type Annotation:The most common approach is to use type annotations during class property declaration. Here, you simply specify the type of the property as number...


Mastering the Parts: Importing Components in TypeScript Projects

Before you import something, it needs to be exported from the original file. This makes it available for other files to use...


Alternative Methods for Handling the "value" Property Error in TypeScript

Breakdown:"The property 'value' does not exist on value of type 'HTMLElement'": This error indicates that you're trying to access the value property on an object that is of type HTMLElement...


Defining TypeScript Callback Types: Boosting Code Safety and Readability

A callback is a function that's passed as an argument to another function. The receiving function can then "call back" the passed function at a later point...



typescript

Understanding TypeScript Constructors, Overloading, and Their Applications

Constructors are special functions in classes that are called when you create a new object of that class. They're responsible for initializing the object's properties (variables) with starting values


Alternative Methods for Setting New Properties on window in TypeScript

Direct Assignment:The most straightforward method is to directly assign a value to the new property:This approach creates a new property named myNewProperty on the window object and assigns the string "Hello


Alternative Methods for Dynamic Property Assignment in TypeScript

Understanding the Concept:In TypeScript, objects are collections of key-value pairs, where keys are property names and values are the corresponding data associated with those properties


Alternative Methods for Type Definitions in Object Literals

Type Definitions in Object LiteralsIn TypeScript, object literals can be annotated with type definitions to provide more precise and informative code


Alternative Methods for Class Type Checking in TypeScript

Class Type Checking in TypeScriptIn TypeScript, class type checking ensures that objects adhere to the defined structure of a class