Skip to content

Narrowing issue: exhaustive pattern matching on a "union" of only one type #47288

Closed
@stepchowfun

Description

@stepchowfun

Bug Report

A pattern that I love in TypeScript is the idea of exhaustive pattern matching on a tagged union:

type Expression =
  | { tag: "constant", value: number }
  | { tag: "add", left: Expression, right: Expression }
  | { tag: "multiply", left: Expression, right: Expression };

export function unreachable(x: never): never {
  return x;
}

function evaluate(expression: Expression): number {
  switch (expression.tag) {
    case "constant":
      return expression.value;
    case "add":
      return evaluate(expression.left) + evaluate(expression.right);
    case "multiply":
      return evaluate(expression.left) * evaluate(expression.right);
    default:
      return unreachable(expression);
  }
}

console.log(
  evaluate({
    tag: "add",
    left: { tag: "constant", value: 2 },
    right: { tag: "constant", value: 3 },
  })
);

If I add a new case to the Expression type (e.g., for subtraction), then the type checker kindly points out all the places I need to update to handle that case. I rely on this heavily and greatly appreciate it.

However, TypeScript reports a type error if there is only one case:

type Expression = { tag: "constant", value: number };

export function unreachable(x: never): never {
  return x;
}

function evaluate(expression: Expression): number {
  switch (expression.tag) {
    case "constant":
      return expression.value;
    default:
      return unreachable(expression); // Argument of type 'Expression' is not assignable to parameter of type 'never'.
  }
}

console.log(evaluate({ tag: "constant", value: 2 }));

This is surprising to me, since I would expect this situation with only 1 case to behave exactly the same as with N cases in general.

One might be tempted to think that this is not useful. But I think there are situations where it makes sense to start with only one case if it is expected that there will be more added in the future (YAGNI might be a valid counterargument in many situations—but I don't believe the type checker should proclaim that).

On a related note, I would also like this to work on types with zero cases (i.e., never), which is justified because such a type is not supposed to be able to be instantiated. I think that would look like this:

type Expression = never; // Zero cases!

export function unreachable(x: never): never {
  return x;
}

function evaluate(expression: Expression): number {
  switch (expression.tag) { // Property 'tag' does not exist on type 'never'.
    default:
      return unreachable(expression);
  }
}

But perhaps I am asking for too much with that example!

Essentially, I want 0 variants and 1 variant to be unremarkable non-special cases of N variants and behave uniformly as such, as they do in languages like Rust and Haskell.

🔎 Search Terms

  • "exhaustive pattern matching"
  • "pattern matching"
  • "algebraic data types"

I apologize if this has been raised before.

🕗 Version & Regression Information

This is the behavior in every version I tried (3.3.3-4.5.4), and I reviewed the FAQ for entries about "Type System Behavior".

⏯ Playground Link

💻 Code

(This is the problematic example from above.)

type Expression = { tag: "constant", value: number };

export function unreachable(x: never): never {
  return x;
}

function evaluate(expression: Expression): number {
  switch (expression.tag) {
    case "constant":
      return expression.value;
    default:
      return unreachable(expression);
  }
}

console.log(evaluate({ tag: "constant", value: 2 }));

🙁 Actual behavior

TypeScript reports this type error:

generated/types.ts:17:26 - error TS2345: Argument of type 'Expression' is not assignable to parameter of type 'never'.

17       return unreachable(expression);
                            ~~~~~~~~~~


Found 1 error.

🙂 Expected behavior

I expect it to type check successfully and print 2 to standard output when executed.

Thank you for reading this bug report!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions