Ensuring Exclusive Properties with TypeScript: Unions vs. Discriminated Unions
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
-
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;
- Create separate interfaces for each property combination:
-
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) } }
- Include a common property (
Explanation:
- Unions allow an object to be of either type
Option1
orOption2
. - 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 eitherT
orU
can exist in the resulting type.- The conditional type logic achieves this by checking if a property from
T
extendsnever
(meaning it's not present). If so, it allows properties fromU
. - 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