Skip to content

Exhaustiveness check does not work for tagged union when there is the only tag #38963

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
BobNobrain opened this issue Jun 6, 2020 · 3 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@BobNobrain
Copy link

TypeScript Version: 3.9.2

Search Terms:

  • typescript exhaustiveness check for single tag
  • typescript exhaustiveness check for single option
  • typescript switch case on tagged union with single item
  • typescript type guards for tagged union of single item

Expected behavior:

No compile errors: we cannot get the default branch, so type of x in there should be narrowed down to never, and the function test is correct.

Actual behavior:

Compile error at line 20 (return absurd(x);):

Argument of type 'Disjoint' is not assignable to parameter of type 'never'.

However, if we uncomment the code that adds the second item to Disjoint, it will be working properly.

Code

enum Labels {RED, YELLOW}

type Disjoint
    = { label: Labels.RED; value: number }
    // | { label: Labels.YELLOW; value: string}
;

const absurd = (u: never): never => { throw new Error() };

function test(x: Disjoint): number {
    switch (x.label) {
        case Labels.RED:
            return x.value;

        // case Labels.YELLOW:
        //     return +x.value;

        default:
            return absurd(x);
    }
}

This may seem a weird corner-case, but I have stumbled this behaviour multiple times. You can have a tagged union of single tag when you plan to extend it later. When you want to ensure that later when you add new tags to it, you will get compile errors in every non-exhaustive switch statement that must be exhaustive.

Output
"use strict";
var Labels;
(function (Labels) {
    Labels[Labels["RED"] = 0] = "RED";
    Labels[Labels["YELLOW"] = 1] = "YELLOW";
})(Labels || (Labels = {}));
const absurd = (u) => { throw new Error(); };
function test(x) {
    switch (x.label) {
        case Labels.RED:
            return x.value;
        // case Labels.YELLOW:
        //     return +x.value;
        default:
            return absurd(x);
    }
}
Compiler Options
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "strictBindCallApply": true,
    "noImplicitThis": true,
    "noImplicitReturns": true,
    "useDefineForClassFields": false,
    "alwaysStrict": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    "downlevelIteration": false,
    "noEmitHelpers": false,
    "noLib": false,
    "noStrictGenericChecks": false,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "esModuleInterop": true,
    "preserveConstEnums": false,
    "removeComments": false,
    "skipLibCheck": false,
    "checkJs": false,
    "allowJs": false,
    "declaration": true,
    "experimentalDecorators": false,
    "emitDecoratorMetadata": false,
    "target": "ES2017",
    "module": "ESNext"
  }
}

Playground Link: Provided

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jun 10, 2020
@RyanCavanaugh
Copy link
Member

We've discussed narrowing-to-never of nonunions (singletons) in other issues and it's unfortunately not a great way forward. There are a few issues with doing this. The first is performance; for non-unions we simply don't do any narrowing logic like this in the first place. The second is that immediately going to never raises a lot of problems in otherwise innocuous code if you start assuming that any observation outside the type system implies never; you can see code fairly frequently like this:

function fn(arg: { x: string }) {
  // Legacy wrapper
  if (arg.x === undefined) {
    // ??Error??, arg is of type never
    arg.x = "";
  }
}

I don't have a super-great workaround off the top of my head that keeps the single-case switch structure in place.

@BobNobrain
Copy link
Author

Ok, thank you for detailed answer. I didn't realize there can exist examples like this because I was lucky enough to not see anything like that =) This in theory can be solved with another --strictNNN flag, but performance issue is surely a showstopper here.

I suppose I should close this now, as this behaviour will not be changed.

@fatcerberus
Copy link

@RyanCavanaugh Doesn't discriminated-union narrowing only apply when the property being checked is a unit type, though? In your example arg.x is string, so .x wouldn't be a discriminator there anyway. And if .x were a unit type, then it seems fair to assume such a check wouldn't exist unless you wanted the narrowing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

3 participants