TypeScript Exhaustive Check Implementation
This challenge focuses on a crucial aspect of robust TypeScript development: ensuring that all possible cases within a discriminated union or enum are handled. Implementing an exhaustive check helps prevent runtime errors and makes your code more maintainable by forcing you to acknowledge and address every possible state or variant.
Problem Description
You are tasked with creating a function or mechanism in TypeScript that can perform an "exhaustive check" on a value that is expected to be one of several possible types, typically a discriminated union. The goal is to ensure that every possible variant of the union is explicitly handled. If an unhandled variant is encountered, the mechanism should signal an error, ideally at compile time or as a clear runtime assertion.
Key Requirements:
- Discriminated Union Handling: The primary target is discriminated unions, where a common property (the discriminant) determines the specific type of the object.
- Compile-Time Safety (Ideal): The most desirable outcome is a mechanism that flags unhandled cases during compilation.
- Runtime Safety (Fallback): If compile-time safety isn't fully achievable for all scenarios, a robust runtime assertion that throws an error should be implemented.
- Genericity: The solution should be as generic as possible to work with various discriminated unions.
- Clarity: The implementation should be clear and easy to understand for other developers.
Expected Behavior:
When a function processes a discriminated union and a switch statement or if-else if chain is used to handle each case, an exhaustive check should ensure that:
- If all possible cases are handled, the function completes without issues.
- If a case is missed, TypeScript should ideally warn you during compilation. If not, a runtime error should be thrown, clearly indicating the unhandled case.
Edge Cases:
- Unions with many possible types.
- Unions where the discriminant property is not a string literal or number.
- Scenarios where the union type might evolve (new variants are added later).
Examples
Example 1: Basic Discriminated Union
Let's consider a Shape type:
type Circle = {
kind: "circle";
radius: number;
};
type Square = {
kind: "square";
sideLength: number;
};
type Triangle = {
kind: "triangle";
base: number;
height: number;
};
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
// Implement exhaustive check here
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
// What happens if a new shape is added?
}
}
Expected Output (with exhaustive check):
If Triangle was accidentally omitted from the switch statement, and Shape was defined as Circle | Square | Triangle, the compiler should ideally issue an error (e.g., Type 'Triangle' is not assignable to type 'never'.) when trying to assign an unhandled Triangle to a variable typed as never. Alternatively, a runtime error could be thrown.
Example 2: Using a Helper Function for Exhaustive Check
A common pattern is to use a helper function that leverages never to achieve compile-time checks.
// Assume Shape, Circle, Square, Triangle as defined above
// Helper function (you'll need to implement this logic)
function assertNever(x: never): never {
// ... implementation ...
}
function getAreaWithHelper(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// If shape.kind is not one of the above,
// TypeScript will try to assign shape.kind to 'never'.
// If it can't, it means a case was missed.
return assertNever(shape); // <-- This is where the check happens
}
}
Expected Output (with assertNever):
If a new shape (e.g., Rectangle) is added to the Shape union, but not to the switch statement, calling getAreaWithHelper with a Rectangle will cause TypeScript to infer that shape (which is now Rectangle) should be assignable to never in the default case. Since Rectangle is not never, TypeScript will flag this as a compile-time error: Argument of type 'Rectangle' is not assignable to parameter of type 'never'.
Example 3: Non-Discriminated Union (and why it's harder)
Consider a union of unrelated types:
type Box = { type: 'box'; width: number; height: number };
type Ball = { type: 'ball'; radius: number };
type Cube = { type: 'cube'; side: number };
type Item = Box | Ball | Cube;
function processItem(item: Item): string {
// How would you ensure all cases are handled here without a clear discriminant?
// This is generally harder and less idiomatic for exhaustive checks.
}
Explanation: This example highlights that discriminated unions (with a common property like kind) are ideal for exhaustive checks. For unions without a clear discriminant, achieving compile-time safety becomes more complex and often requires casting or other less elegant solutions.
Constraints
- The solution must be written in TypeScript.
- The solution should aim for compile-time safety where possible, falling back to clear runtime assertions.
- The implementation should be efficient and not introduce significant performance overhead.
- The focus is on correctly handling discriminated unions.
Notes
- The
nevertype in TypeScript is crucial for achieving compile-time exhaustive checks. When a variable is assigned tonever, it means that no possible value can be assigned to it, effectively signaling an impossible state. - Consider how your solution will behave when new variants are added to a discriminated union. A good exhaustive check mechanism should make these additions immediately apparent as compile-time errors if the handling logic is not updated.
- Think about the difference between compile-time errors and runtime errors. Compile-time errors are preferred as they catch issues before the code is even run.
- You might want to research common patterns for implementing exhaustive checks in TypeScript, such as the
assertNeverhelper function pattern.