Skip to content

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

Closed
@OliverJAsh

Description

@OliverJAsh

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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancement: plugin rule optionNew rule option for an existing eslint-plugin rulepackage: eslint-pluginIssues related to @typescript-eslint/eslint-plugintriageWaiting for team members to take a look

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions