Limitation with exhaustiveness checking in if statements
Opened this issue ยท 2 comments
๐ Search Terms
Hello!
I was trying some error handling with a different approach using the return early pattern, and came across this. Apparently, even if I check all possible errors defined in the return type of the method, I still can't get Typescript to understand that the "happy result" should be accessible.
I don't know if it is a bug. I also did not find any other issue mentioning this and would like to understand better if this is a known limitation or not. Thanks!
๐ Version & Regression Information
- I was unable to test this on prior versions because I believe it is probably a design decision
โฏ Playground Link
๐ป Code
// Demonstrating TypeScript's limitation with exhaustiveness checking in if statements
// Define a Result type similar to Rust/Go error handling
type Result<T, E> = { value: T; error?: never } | { error: E; value?: never };
const ok = <T>(value: T): Result<T, never> => ({ value });
const err = <E>(error: E): Result<never, E> => ({ error });
type Success = { data: "success!" };
// Define error types
class InvalidInputError extends Error {
public readonly message = "invalid input" as const;
constructor(public issues: string[]) {
super();
}
}
class NotFoundError extends Error {
public readonly message = "not found" as const;
constructor(public id: string) {
super();
}
}
class UnauthorizedError extends Error {
public readonly message = "unauthorized" as const;
constructor(public userId: string) {
super();
}
}
type AppError = InvalidInputError | NotFoundError | UnauthorizedError;
// Simulate a function that returns Result
function performOperation(): Result<Success, AppError> {
const random = Math.random();
if (random < 0.25) {
return err(new InvalidInputError(["field1", "field2"]));
}
if (random < 0.5) {
return err(new NotFoundError("123"));
}
if (random < 0.75) {
return err(new UnauthorizedError("user-456"));
}
return ok({ data: "success!" });
}
// =============================================================================
// PROBLEM: TypeScript doesn't know that after checking all errors, value exists
// =============================================================================
function handleResult_WithInstanceOf() {
const result = performOperation();
console.log(result.error); // (property) error?: AppError | undefined
console.log(result.value); // (property) value?: Success | undefined
// Check all possible error types
if (result.error instanceof InvalidInputError) {
console.log("Invalid input:", result.error.issues);
return;
}
if (result.error instanceof NotFoundError) {
console.log("Not found:", result.error.id);
return;
}
if (result.error instanceof UnauthorizedError) {
console.log("Unauthorized:", result.error.userId);
return;
}
// At this point, we've exhaustively checked all error types
// TypeScript SHOULD know that result.value exists and is NOT undefined
// But it doesn't! result.value is still typed as `Success | undefined`
console.log(result.error); // (property) error?: undefined
console.log(result.value); // (property) value?: Success | undefined
// @ts-expect-error - This should NOT be needed, but TypeScript requires it
const data: { data: string } = result.value; // Type 'Success | undefined' is not assignable to type '{ data: string; }'.
}
// =============================================================================
// SAME PROBLEM: Using string literal comparisons instead of instanceof
// =============================================================================
function handleResult_WithStringLiterals() {
const result = performOperation();
console.log(result.error); // (property) error?: AppError | undefined
console.log(result.value); // (property) value?: Success | undefined
// Check all possible error types using string literals
if (result.error?.message === "invalid input") {
console.log("Invalid input:", result.error.issues);
return;
}
if (result.error?.message === "not found") {
console.log("Not found:", result.error.id);
return;
}
if (result.error?.message === "unauthorized") {
console.log("Unauthorized:", result.error.userId);
return;
}
// Same problem: TypeScript still thinks result.value could be undefined
console.log(result.error); // (property) error?: undefined
console.log(result.value); // (property) value?: Success | undefined
// @ts-expect-error - Should not be needed
const data: { data: string } = result.value; // Error: Type 'Success | undefined' is not assignable
}
// =============================================================================
// DESIRED BEHAVIOR (what should work but doesn't)
// =============================================================================
function handleResult_Desired() {
const result = performOperation();
// Fast return for each error type (same order as in performOperation)
if (result.error instanceof InvalidInputError) {
console.log("Invalid input:", result.error.issues);
return;
}
if (result.error instanceof NotFoundError) {
console.log("Not found:", result.error.id);
return;
}
if (result.error instanceof UnauthorizedError) {
console.log("Unauthorized:", result.error.userId);
return;
}
// TypeScript SHOULD know that:
// - We've checked all members of the AppError union
// - result.error must be 'never' at this point
// - Therefore result.value must exist and is NOT undefined
// This SHOULD work without any assertion:
const data: { data: string } = result.value; // โ Currently errors, but SHOULD work
console.log(data);
}
// =============================================================================
// SUMMARY
// =============================================================================
/*
The desired pattern is:
1. Define a Result<T, E> type for error handling
2. Return early for each specific error in order
3. After all error checks, TypeScript should infer that the value exists
This pattern works in:
- Go (with type switches)
- Rust (with pattern matching)
- Other languages with exhaustiveness checking
But TypeScript cannot do this because its control flow analysis doesn't track
that sequential if statements have exhausted all members of a union type.
This is true regardless of whether you use:
- instanceof checks
- string literal comparisons
- custom type guards
- any other narrowing technique
The only way to get exhaustiveness checking in TypeScript is:
- Switch statements on discriminated unions
- Explicit assertNever patterns
- But both lose the clean fast-return pattern we want
*/๐ Actual behavior
The infer type of the "happy result" did not take into account all the validation happened.
๐ Expected behavior
After checking all possible errors from the return type of the method called, the "happy result" should be accessible with the right type.
Additional information about the issue
No response
Seems like this may be a duplicate of #18758 and/or #42384?
(Also, for future reference: minimal reproductions are better. This is a lot of code to demonstrate the issue.)
The usual workaround is to put a top-level discriminant (must be a literal type) in your Result type to indicate which variant it is. This is conceptually how it works in Rust too, with the enum member's name/ID serving as the discriminant (I believe that can often be optimized away by the compiler, but with a type like Result<String, String> that's not possible).
Also, this isn't true:
The only way to get exhaustiveness checking in TypeScript is:
- Switch statements on discriminated unions
- Explicit assertNever patterns