Skip to content

[switch-exhaustiveness-check] ban the default case to enforce all cases are handled and prevent accidental mishandling of new cases #3616

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
OliverJAsh opened this issue Jul 7, 2021 · 14 comments · Fixed by #7539
Labels
enhancement: plugin rule option New rule option for an existing eslint-plugin rule package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look

Comments

@OliverJAsh
Copy link
Contributor

I frequently see code like this:

type MyUnion = 'a' | 'b';

declare const myUnion: MyUnion;

const f1 = () => (myUnion === 'a' ? 'a' : 'b');

f1('a') // 'a'
f1('b') // 'b'

The logic in f1 makes an assumption: if myUnion is not a, it must be b.

Later on, someone might update the MyUnion type and this assumption will breakdown:

-type MyUnion = 'a' | 'b';
+type MyUnion = 'a' | 'b' | 'c';

The runtime behaviour is clearly incorrect, yet TypeScript will not error to remind us that we need to update the logic in f1:

f1('a') // 'a'
f1('b') // 'b'
f1('c') // 'b' ❌

This problem is not specific to the ternary operator but also if and switch statements:

const f2 = () => {
    if (myUnion === 'a') {
        return 'a';
    } else {
        return 'b';
    }
};

const f3 = () => {
    switch (myUnion) {
        case 'a':
            return 'a';
        default:
            return 'b';
    }
};

As we can see, it is not safe to make assumptions about the value that reaches the else/default case because it can change.

Instead we need to explicitly specify all cases:

import assertNever from 'assert-never';

type MyUnion = 'a' | 'b';

declare const myUnion: MyUnion;

const f2 = () => {
    if (myUnion === 'a') {
        return 'a';
    } else if (myUnion === 'b') {
        return 'b';
    } else {
        assertNever(myUnion);
    }
};

const f3 = () => {
    switch (myUnion) {
        case 'a':
            return 'a';
        case 'b':
            return 'b';
    }
};

const f3b = () => {
    switch (myUnion) {
        case 'a':
            return 'a';
        case 'b':
            return 'b';
        default:
            assertNever(myUnion);
    }
};

This way, when the type is eventually widened, TypeScript will generate a type error so we're reminded that we need to update our code:

import assertNever from 'assert-never';

type MyUnion = 'a' | 'b' | 'c';

declare const myUnion: MyUnion;

const f2 = () => {
    if (myUnion === 'a') {
        return 'a';
    } else if (myUnion === 'b') {
        return 'b';
    } else {
        // Argument of type 'string' is not assignable to parameter of type 'never'.
        assertNever(myUnion);
    }
};

// @noImplicitReturns: true
// Not all code paths return a value.
const f3 = () => {
    switch (myUnion) {
        case 'a':
            return 'a';
        case 'b':
            return 'b';
    }
};

const f3b = () => {
    switch (myUnion) {
        case 'a':
            return 'a';
        case 'b':
            return 'b';
        default:
            // Argument of type 'string' is not assignable to parameter of type 'never'.
            assertNever(myUnion);
    }
};

I would like to propose a rule that enforces this. The rule would report an error inside a ternary or if/switch statement if we're switching over a union type (except boolean) and we have a fallback case (else/default). The fix would be to explicitly specify all cases.

I'm really not sure what we would call it.

WDYT?

@OliverJAsh OliverJAsh added package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look labels Jul 7, 2021
@bradzacher
Copy link
Member

Existing rule that is somewhat related to this request - switch-exhaustiveness-check


For switches it's definitely easy to parse, understand, and report against.
Switches are unambiguous in their subject and the cases that are checked - so it's easy to see.

When paired with switch-exhaustiveness-check it would allow you to easily ensure all switches are always guaranteed to be exhaustive!

However ifs are a lot more tricky.
Solving for if's is a "hard problem"™ to solve because they support arbitrary logic that can be in weird combinations and orderings, and can be interleaved with other checks.

For example

type MyUnion = 'a' | 'b' | 'c';

declare const myUnion: MyUnion;
if (myUnion === 'a' || myUnion === 'b') {
  // ...
} else {
  // ...
}

if (myUnion !== 'b' && myUnion !== 'c') {
  // ...
} else {
  // ...
}

if (myUnion === 'a' && otherCondition === 'someValue') {
  // ...
} else if ((myUnion === 'b' || myUnion === 'c') && otherCondition === 'other') {
  // ...
}

@bradzacher bradzacher added awaiting response Issues waiting for a reply from the OP or another party and removed triage Waiting for team members to take a look labels Jul 7, 2021
@OliverJAsh
Copy link
Contributor Author

TS seems to understand the control flow and narrow the type, so I was hoping we could benefit from this information in a lint rule—rather than trying to infer it for ourselves.

type MyUnion = 'a' | 'b' | 'c';

declare const myUnion: MyUnion;
if (myUnion === 'a' || myUnion === 'b') {
  // ...
} else {
  myUnion; // TS knows this is `'c'`
}

if (myUnion !== 'b' && myUnion !== 'c') {
  // ...
} else {
    myUnion; // TS knows this is `'b' | 'c'`
}

declare const otherCondition: 'someValue' | 'other' | 'foo'

if (myUnion === 'a' && otherCondition === 'someValue') {
  // ...
} else if ((myUnion === 'b' || myUnion === 'c') && otherCondition === 'other') {
    myUnion; // TS knows this is `'b' | 'c'`
}

Is that possible?

@OliverJAsh
Copy link
Contributor Author

Ah I think I see what you're saying now, my bad!

@bradzacher
Copy link
Member

yeah the problem isn't know what type the thing is - we can definitely do that whenever we've got the identifier in the code.

The problems are:

(1) an if can have multiple things as its subject - so it's really difficult for static analysis to handle this.
Eg the last case I listed - what if otherCondition is also a union type? Would it complain about both?

(2) I don't believe that there's an easy way for us to speculatively ask TS things at certain points in the code. Eg "hey TS, we have an else block - tell us type of this identifier within this block based on the control-flow".

(3) Because ifs are so flexible, it's really hard to understand developer intent or provide strong lint guidance.

For example:

declare const myUnion: 'a' | 'b' | 'c';
if (myUnion === 'a') {
 // ..
} else {
 // ..
}

Is this a bad if? Should it instead be listing else if (myUnion === 'b' || myUnion === 'c')?
What about if there are n members of the union, and only 1/2 of them are checked?
What do you do if there are negative matchers?

The logic and number of cases sort of explodes every quickly for ifs.


But switch/cases are very simple in structure and are thus easy to deal with :)

@OliverJAsh
Copy link
Contributor Author

That makes sense, thanks for explaining.

I guess for switches it could be as simple as banning default, in combination with switch-exhaustiveness-check?

@bradzacher bradzacher added enhancement: plugin rule option New rule option for an existing eslint-plugin rule and removed awaiting response Issues waiting for a reply from the OP or another party labels Jul 8, 2021
@bradzacher
Copy link
Member

I'd be happy to see an option in switch-exhaustiveness-check which bans default

@bradzacher bradzacher changed the title Rule proposal: when switching over unions, disallow fallbacks [switch-exhaustiveness-check] ban the default case to enforce all cases are handled and prevent accidental mishandling of new cases Jul 8, 2021
@k-funk
Copy link

k-funk commented Jul 23, 2021

Similarly, I'd like an option to simultaneously allow default AND require all cases on a switch.

const HandledErrorCodes = [1,2,3]
type HandledErrorCodesType = typeof HandledErrorCodes[number] // 1 | 2 | 3
...
async foo() {
  try {
    await ApiCall()
  } catch (err) { 
    if (!HandledErrorCodes.includes(err)) { Sentry.captureException(err) } // report unknown errors
    this.setState({ err })
  }
}

render() {
  switch (this.state.err) { 
    case 1: return 'Error 1'
    case 2: return 'Error 2'
    // case 3 missing. linter warning wanted
    default: return 'whoops' // i do want a default as a catchall, for unknown errors
  }
}

@JoshuaKGoldberg JoshuaKGoldberg added the accepting prs Go ahead, send a pull request that resolves this issue label Oct 25, 2021
@k-funk
Copy link

k-funk commented Nov 7, 2021

@k-funk Isn't that https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md?

It doesn't appear so. The difference is that I am looking for exhaustive AND an optional default. That rule seems to turn off exhaustiveness once default is used.

@Josh-Cena
Copy link
Member

Josh-Cena commented Mar 18, 2023

So to summarize. Here are the things being proposed here. For a non-exhaustive switch that...

Has default No default
switch (literal union); non-exhaustive 1
declare const literal: "a" | "b";

switch (literal) {
  case "a": break;
  default: break;
}
2
declare const literal: "a" | "b";

switch (literal) {
  case "a": break;
}
switch (literal union);exhaustive 3
declare const literal: "a" | "b";

switch (literal) {
  case "a": break;
  case "b": break;
  default: break;
}
4
declare const literal: "a" | "b";

switch (literal) {
  case "a": break;
  case "b": break;
}
switch (infinite type)* 5
declare const infinite: string;

switch (infinite) {
  case "a": break;
  case "b": break;
  default: break;
}
6
declare const infinite: string;

switch (literal) {
  case "a": break;
  case "b": break;
}

*Note: switches against infinite types are always non-exhaustive by definition.

Rule + Options Errors for...
default-case 2, 4, 6
switch-exhaustiveness-check 2
switch-exhaustiveness-check + banDefault 1, 2, 3, 5
switch-exhaustiveness-check + ignoreDefault 1, 2
switch-exhaustiveness-check + requireDefaultForInfinite (#2959) 2, 6

Is this correct?

@jakeleventhal
Copy link

switch-exhaustiveness-check + ignoreDefault is what i would like to see

@jakeleventhal
Copy link

jakeleventhal commented Jan 2, 2024

@JoshuaKGoldberg there is still a bug here:

    '@typescript-eslint/switch-exhaustiveness-check': [
      'error',
      {
        allowDefaultCaseForExhaustiveSwitch: false,
        requireDefaultForNonUnion: true
      }
    ],
    const getRandomString = (): string => 'asdf';

    const x = getRandomString();
    switch (x) {
      case 'aszdfasdf':
        break;
      default:
      ^^^^^^^
        break;
        ^^^^^
    }

The switch statement is exhaustive, so the default case is unnecessary.eslint[@typescript-eslint/switch-exhaustiveness-check](https://typescript-eslint.io/rules/switch-exhaustiveness-check)

The switch statement is not exhaustive.

@jakeleventhal
Copy link

This needs to reopen

@JoshuaKGoldberg JoshuaKGoldberg added triage Waiting for team members to take a look and removed accepting prs Go ahead, send a pull request that resolves this issue labels Jan 2, 2024
@Josh-Cena
Copy link
Member

Duplicate of #8165. This issue is for implementing a feature; bugs related to the feature are separate issues.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jan 11, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement: plugin rule option New rule option for an existing eslint-plugin rule package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look
Projects
None yet
6 participants