Skip to content

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

Closed

Description

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

Activity

  1. RyanCavanaugh commented on Jun 10, 2020

    @RyanCavanaugh
    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.

  2. BobNobrain commented on Jun 10, 2020

    @BobNobrain
    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.

  3. fatcerberus commented on Jun 19, 2020

    @fatcerberus

    Ryan Cavanaugh (@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.

  4. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions