# rules/eslint/closure-discriminated-union-must-be-exhaustive.eslint.rule.ts

> 167 lines of code and 35 definitions.

Tree: Governance tree
Language: typescript
Canonical: https://banes-lab.com/anatomy/governance#file-governance-rules-eslint-closure-discriminated-union-must-be-exhaustive-eslint-rule-ts
Source text: https://banes-lab.com/source/governance/rules/eslint/closure-discriminated-union-must-be-exhaustive.eslint.rule.ts.txt

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/closure-build-script-unimported.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/closure-build-script-unimported.eslint.rule.ts.md) and before [rules/eslint/closure-event-system-has-trajectory.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/closure-event-system-has-trajectory.eslint.rule.ts.md).

## Definitions

- `declaresNever` (lexical_declaration, line 73)
- `isAssertNever` (lexical_declaration, line 88)
- `callsNever` (lexical_declaration, line 80)
- `isExemptFile` (lexical_declaration, line 27)
- `memberKey` (lexical_declaration, line 32)
- `create` (method_definition, line 131, exported)
- `extractDiscriminator` (lexical_declaration, line 62)
- `ifStatement` (method_definition, line 137, exported)
- `EXEMPT_BASENAME_SUFFIXES` (lexical_declaration, line 16)
- `MIN_CHAIN_LENGTH` (lexical_declaration, line 17)
- `EQUALITY_OPERATORS` (lexical_declaration, line 18)
- `NEVER_MARKER` (lexical_declaration, line 19)
- `basenameOf` (lexical_declaration, line 21)
- `norm` (lexical_declaration, line 22)
- `idx` (lexical_declaration, line 23)
- `basename` (lexical_declaration, line 28)
- `object` (lexical_declaration, line 42)
- `property` (lexical_declaration, line 43)
- `literalText` (lexical_declaration, line 50)
- `text` (lexical_declaration, line 54)
- `numeric` (lexical_declaration, line 58)
- `key` (lexical_declaration, line 68)
- `value` (lexical_declaration, line 69)
- `annotation` (lexical_declaration, line 75)
- `expression` (lexical_declaration, line 81)
- `walkChain` (lexical_declaration, line 109)
- `branches` (lexical_declaration, line 110)
- `node` (lexical_declaration, line 111)
- `alternate` (lexical_declaration, line 118)
- `seen` (lexical_declaration, line 135, exported)
- `disc` (lexical_declaration, line 141, exported)
- `result` (lexical_declaration, line 145, exported)
- `walker` (lexical_declaration, line 149, exported)
- `next` (lexical_declaration, line 152, exported)
- `payload` (lexical_declaration, line 159, exported)

## Uses

- [shared/factories/listener.factory.ts](https://banes-lab.com/source/governance/shared/factories/listener.factory.ts.md)
- [shared/selectors/syntax.selector.ts](https://banes-lab.com/source/governance/shared/selectors/syntax.selector.ts.md)

## Records this file checks

- [Type Safety](https://banes-lab.com/records/architecture/type-safety.md)

## Source

```typescript
import type { LocalRule, RuleContext, RuleListener } from "../../types/rule.types.ts";
import {
    calleeName,
    isType,
    literalString,
    nameOf,
    nodeAt,
    nodesAt,
    numberAt,
    stringAt,
} from "../../shared/selectors/syntax.selector.ts";
import type { AstNode } from "../../types/syntax.types.ts";
import { defineCheck } from "@govlab/context/check";
import { listener } from "../../shared/factories/listener.factory.ts";

const EXEMPT_BASENAME_SUFFIXES = [".test.ts", ".spec.ts"];
const MIN_CHAIN_LENGTH = 3;
const EQUALITY_OPERATORS = new Set(["===", "=="]);
const NEVER_MARKER = "never";

const basenameOf = function basenameOf(path: string): string {
    const norm = path.split("\\").join("/");
    const idx = norm.lastIndexOf("/");
    return idx === -1 ? norm : norm.slice(idx + 1);
};

const isExemptFile = function isExemptFile(filename: string): boolean {
    const basename = basenameOf(filename);
    return EXEMPT_BASENAME_SUFFIXES.some((suffix) => basename.endsWith(suffix));
};

const memberKey = function memberKey(node: AstNode | null): string | null {
    if (node === null) {
        return null;
    }
    if (node.type === "Identifier") {
        return nameOf(node);
    }
    if (node.type !== "MemberExpression") {
        return null;
    }
    const object = memberKey(nodeAt(node, "object"));
    const property = nodeAt(node, "property");
    if (object === null || !isType(property, "Identifier")) {
        return null;
    }
    return `${object}.${nameOf(property)}`;
};

const literalText = function literalText(node: AstNode | null): string | null {
    if (node?.type !== "Literal") {
        return null;
    }
    const text = literalString(node);
    if (text !== null) {
        return text;
    }
    const numeric = numberAt(node, "value");
    return numeric === null ? null : String(numeric);
};

const extractDiscriminator = function extractDiscriminator(
    test: AstNode | null,
): { key: string; value: string } | null {
    if (test?.type !== "BinaryExpression" || !EQUALITY_OPERATORS.has(stringAt(test, "operator"))) {
        return null;
    }
    const key = memberKey(nodeAt(test, "left"));
    const value = literalText(nodeAt(test, "right"));
    return key === null || value === null ? null : { key, value };
};

const declaresNever = function declaresNever(statement: AstNode): boolean {
    return nodesAt(statement, "declarations").some((d) => {
        const annotation = nodeAt(nodeAt(d, "id"), "typeAnnotation");
        return isType(nodeAt(annotation, "typeAnnotation"), "TSNeverKeyword");
    });
};

const callsNever = function callsNever(statement: AstNode): boolean {
    const expression = nodeAt(statement, "expression");
    if (!isType(expression, "CallExpression")) {
        return false;
    }
    return calleeName(expression).toLowerCase().includes(NEVER_MARKER);
};

const isAssertNever = function isAssertNever(node: AstNode | null): boolean {
    if (node === null) {
        return false;
    }
    if (node.type === "ThrowStatement") {
        return true;
    }
    if (node.type !== "BlockStatement") {
        return false;
    }
    return nodesAt(node, "body").some((s) => {
        if (s.type === "ThrowStatement") {
            return true;
        }
        if (s.type === "ExpressionStatement") {
            return callsNever(s);
        }
        return s.type === "VariableDeclaration" && declaresNever(s);
    });
};

const walkChain = function walkChain(start: AstNode, key: string): { branches: string[]; final: AstNode | null } {
    const branches: string[] = [];
    let node: AstNode = start;
    while (node.type === "IfStatement") {
        const disc = extractDiscriminator(nodeAt(node, "test"));
        if (disc?.key !== key) {
            return { branches, final: node };
        }
        branches.push(disc.value);
        const alternate = nodeAt(node, "alternate");
        if (alternate === null) {
            return { branches, final: null };
        }
        if (alternate.type !== "IfStatement") {
            return { branches, final: alternate };
        }
        node = alternate;
    }
    return { branches, final: null };
};

export default {
    create(context: RuleContext): RuleListener {
        if (isExemptFile(context.filename)) {
            return {};
        }
        const seen = new WeakSet<AstNode>();
        return listener({
            ifStatement(view, node) {
                if (seen.has(view)) {
                    return;
                }
                const disc = extractDiscriminator(nodeAt(view, "test"));
                if (disc === null) {
                    return;
                }
                const result = walkChain(view, disc.key);
                if (result.branches.length < MIN_CHAIN_LENGTH) {
                    return;
                }
                let walker: AstNode = view;
                while (walker.type === "IfStatement") {
                    seen.add(walker);
                    const next = nodeAt(walker, "alternate");
                    if (next === null) {
                        break;
                    }
                    walker = next;
                }
                if (result.final === null || !isAssertNever(result.final)) {
                    const payload = { key: disc.key, values: result.branches.join(", ") };
                    context.report({ data: payload, messageId: "notExhaustive", node });
                }
            },
        });
    },
    meta: {
        docs: {
            checks: defineCheck({ detects: [], enforces: ["architecture:type-safety"] }),
            description:
                'When an if/else-if chain switches on a discriminator (`x.kind === "a"` / `x.slot === "b"`), the terminal arm must be an exhaustiveness assertion — either a final `else { throw new Error(...) }` or `assertNever(x.kind)`. Without it, adding a new discriminator value silently drops contributions through the chain. Type-union additions become silent dead-letter holes.',
        },
        messages: {
            notExhaustive:
                "If/else-if chain on `{{ key }}` (values {{ values }}) has no exhaustiveness terminal. Add `else { throw new Error(...) }` or `else { const _exhaustive: never = {{ key }}; }`. Without it, adding a new value to the union silently drops cases here.",
        },
        schema: [],
        type: "problem",
    },
} satisfies LocalRule;
```
