Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
stepchowfun opened this issue Jan 2, 2022 · 4 comments
Closed

Comments

@stepchowfun
Copy link

stepchowfun commented Jan 2, 2022

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!

@MartinJohns
Copy link
Contributor

Duplicate of #38963.

@stepchowfun
Copy link
Author

Ah, thanks! I missed it since I only searched for open issues. So it seems like this is simply "wont fix"—is that right?

One reason I'd like this to work is because I'm currently writing a code generator (à la Protocol Buffers/Thrift), and generating valid TypeScript code despite these special cases in the type system is proving to be somewhat laborious and error-prone. But perhaps code generators are too niche of a use case to motivate a fix for this.

@MartinJohns
Copy link
Contributor

MartinJohns commented Jan 2, 2022

Here's a workaround proposed by the TypeScript team:
#46978 (comment)

And it's not a "won't fix", but it's a "there's nothing to fix". It's not a bug, it's working as intended.

@stepchowfun
Copy link
Author

That workaround works for me! Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants