eslint-plugin-switch-statement
Version:
Rules for properly handling switch statements, including ensuring that appropriate exhaustive case handling.
232 lines (231 loc) • 12.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RULE_NAME = exports.DEFAULT_ASSERT_NEVER_FN_NAME = void 0;
const utils_1 = require("@typescript-eslint/utils");
const typescript_1 = __importDefault(require("typescript"));
exports.DEFAULT_ASSERT_NEVER_FN_NAME = "assertUnreachable";
exports.RULE_NAME = "require-appropriate-default-case";
const messages = {
addDefaultCase: "Every switch statement must have a `default` case, which should either " +
"have Typescript verify that the switch is exhaustive (by including a " +
"function call that will trigger a type error unless the variable's type " +
"has been narrowed to never) or should have generic fallback logic.",
considerExhaustiveSwitch: "This switch statement is switching over a set of known values, so it " +
"could handle them exhaustively, with a `default` case that calls " +
"`{{ exhaustiveFunctionName }}`, to have TS verify the exhaustiveness. " +
"Consider updating your `default` case to have that exhaustiveness check, " +
"or disable the lint rule on this `switch` if you're sure you want to " +
"apply general fallback logic (e.g., to all future cases).",
};
exports.default = utils_1.ESLintUtils.RuleCreator.withoutDocs({
defaultOptions: [
{
unreachableDefaultCaseAssertionFunctionName: exports.DEFAULT_ASSERT_NEVER_FN_NAME,
},
],
meta: {
type: "problem",
fixable: "code",
docs: {
description: "require unreachable default case",
url: "https://github.com/ethanresnick/eslint-plugin-exhaustive-switch/blob/main/docs/rules/require-unreachable-default-case.md",
},
schema: [
{
type: "object",
properties: {
unreachableDefaultCaseAssertionFunctionName: {
type: "string",
default: exports.DEFAULT_ASSERT_NEVER_FN_NAME,
},
},
},
],
messages,
},
create(context, options) {
const sourceCode = context.sourceCode;
const services = utils_1.ESLintUtils.getParserServices(context);
if (!services || !services.getTypeAtLocation) {
throw new Error("This rule requires type checking to be available");
}
const checker = services.program.getTypeChecker();
const { getTypeAtLocation } = services;
const exhaustiveFunctionName = options[0].unreachableDefaultCaseAssertionFunctionName;
function isCandidateForExhaustiveSwitch(type) {
if ((type.flags & typescript_1.default.TypeFlags.Unit) > 0)
return true;
if (type.isUnion()) {
return type.types.every((t) => isCandidateForExhaustiveSwitch(t));
}
if (type.isIntersection()) {
return type.types.some((t) => isCandidateForExhaustiveSwitch(t));
}
// For everything else, try to check their constraint. This handles type
// parameters, but also things like conditional types and, probably, index
// access types.
const constraint = checker.getBaseConstraintOfType(type);
return constraint ? isCandidateForExhaustiveSwitch(constraint) : false;
}
// Keep a stack of switch statements as we traverse down into them.
const switchStatements = [];
return {
SwitchStatement(node) {
switchStatements.push({
node,
switchesOnLiteralTypeUnion: isCandidateForExhaustiveSwitch(getTypeAtLocation(node.discriminant)),
// if we find one, we'll change this below.
hasDefaultCase: false,
defaultCaseCallsExhaustiveFunction: false,
});
},
"SwitchCase[test=null]"(_node) {
// If we're in a switch case, we know we're in a switch statement.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
switchStatements.at(-1).hasDefaultCase = true;
},
[`SwitchCase[test=null] CallExpression[callee.name=${exhaustiveFunctionName}]`](_node) {
// If we're in a switch case, we know we're in a switch statement.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
switchStatements.at(-1).defaultCaseCallsExhaustiveFunction = true;
},
"SwitchStatement:exit"(node) {
const switchStatement = switchStatements.pop();
if ((switchStatement === null || switchStatement === void 0 ? void 0 : switchStatement.node) !== node) {
throw new Error("Unexpected switch statement exit");
}
const { switchesOnLiteralTypeUnion, hasDefaultCase, defaultCaseCallsExhaustiveFunction, } = switchStatement;
// If the node doesn't switch on a union of literal types, and does have
// a default case, it must be ok, because we don't require a call to the
// exhaustiveness check function when the switch isn't on a literal union.
if (hasDefaultCase && !switchesOnLiteralTypeUnion) {
return;
}
// If the switch is on a union of literal types, we must check for an
// assert unreachable call in its default.
if (hasDefaultCase && switchesOnLiteralTypeUnion) {
if (defaultCaseCallsExhaustiveFunction) {
return;
}
else {
context.report({
node,
data: { exhaustiveFunctionName },
messageId: "considerExhaustiveSwitch",
});
return;
}
}
// If the switch doesn't have a default case, and it switches on a union
// of literal types, we report an "add default case error", but this one
// is fixable (because we can synthesize a default case that calls the
// exhaustiveness check function).
if (!hasDefaultCase && switchesOnLiteralTypeUnion) {
context.report({
node,
messageId: "addDefaultCase",
fix(fixer) {
const argumentNode = getExhaustivenessCheckFunctionArg(node);
if (!argumentNode) {
return null;
}
const switchStatementSourceCode = sourceCode.getText(node);
const argSourceCode = sourceCode.getText(argumentNode);
// We assume the source code for the switch statement always ends
// with a `}`, and that the default case should come last, so we
// trim off the trailing `}` and add the default case. This logic
// feels more brittle than `insertTextAfter(nodeForLastCase)`, but
// that logic wouldn't work for empty switch statements, so we'd
// need something like this anyway.
//
// NB: We add a new line above the default to reduce the cases
// where this would produce invalid Javascript (e.g., if the prior
// case statement doesn't end with a `;` and isn't in a a block),
// but the nature of string-based fixers is that there will
// probably always be some.
return fixer.replaceTextRange(node.range, switchStatementSourceCode.slice(0, -1) +
"\ndefault: " +
`${exhaustiveFunctionName}(${argSourceCode}); }`);
},
});
}
// Finally, if the switch doesn't have a default case and doesn't switch
// on a union of literal types, we report an "add default case" error,
// but this one is unfixable, because it's unclear what the default case
// should do.
if (!hasDefaultCase && !switchesOnLiteralTypeUnion) {
context.report({
node,
messageId: "addDefaultCase",
});
}
},
};
},
});
function assertUnreachable(it) {
throw new Error(`Unreachable case: ${JSON.stringify(it)}`);
}
function getExhaustivenessCheckFunctionArg(node) {
switch (node.discriminant.type) {
case utils_1.TSESTree.AST_NODE_TYPES.ArrayPattern:
case utils_1.TSESTree.AST_NODE_TYPES.ArrowFunctionExpression:
case utils_1.TSESTree.AST_NODE_TYPES.AssignmentExpression:
case utils_1.TSESTree.AST_NODE_TYPES.AwaitExpression:
case utils_1.TSESTree.AST_NODE_TYPES.BinaryExpression:
case utils_1.TSESTree.AST_NODE_TYPES.CallExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ChainExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ClassExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ConditionalExpression:
case utils_1.TSESTree.AST_NODE_TYPES.FunctionExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ImportExpression:
case utils_1.TSESTree.AST_NODE_TYPES.JSXElement:
case utils_1.TSESTree.AST_NODE_TYPES.JSXFragment:
case utils_1.TSESTree.AST_NODE_TYPES.Literal:
case utils_1.TSESTree.AST_NODE_TYPES.TemplateLiteral:
case utils_1.TSESTree.AST_NODE_TYPES.LogicalExpression:
case utils_1.TSESTree.AST_NODE_TYPES.MetaProperty:
case utils_1.TSESTree.AST_NODE_TYPES.NewExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ObjectExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ObjectPattern:
case utils_1.TSESTree.AST_NODE_TYPES.SequenceExpression:
case utils_1.TSESTree.AST_NODE_TYPES.TSAsExpression:
case utils_1.TSESTree.AST_NODE_TYPES.TSSatisfiesExpression:
case utils_1.TSESTree.AST_NODE_TYPES.Super:
case utils_1.TSESTree.AST_NODE_TYPES.TaggedTemplateExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ThisExpression:
case utils_1.TSESTree.AST_NODE_TYPES.TSInstantiationExpression:
case utils_1.TSESTree.AST_NODE_TYPES.TSNonNullExpression:
case utils_1.TSESTree.AST_NODE_TYPES.TSTypeAssertion:
case utils_1.TSESTree.AST_NODE_TYPES.UnaryExpression:
case utils_1.TSESTree.AST_NODE_TYPES.UpdateExpression:
case utils_1.TSESTree.AST_NODE_TYPES.YieldExpression:
case utils_1.TSESTree.AST_NODE_TYPES.ArrayExpression: {
return undefined;
}
case utils_1.TSESTree.AST_NODE_TYPES.Identifier:
return node.discriminant;
// If we're switching on a member expression, it's _usually_ a discriminant,
// in which case the object will be narrowed to never, so we want to return
// the object (not the member expression itself) as the argument to the call
// to the exhaustiveness check function, but only if it's an identifier; if
// it was something dynamic -- like `switch(callSomeFunction().foo)` -- then
// we don't want to evaluate the expression again in the default case.
// Granted, the default case should be unreachable, but it's still unsafe if
// the types are inaccurate, and it's unlikely to be useful because TS
// doesn't know that callSomeFunction() returns a constant value.
case utils_1.TSESTree.AST_NODE_TYPES.MemberExpression: {
const object = node.discriminant.object;
if (object.type === utils_1.TSESTree.AST_NODE_TYPES.Identifier) {
return object;
}
return undefined;
}
default: {
assertUnreachable(node.discriminant);
}
}
}