UNPKG

typesafe-ts

Version:

TypeScript utilities for type-safe error handling and optional values

470 lines (467 loc) 24 kB
/* Copyright (c) 2025 Allan Deutsch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import { AST_NODE_TYPES, ESLintUtils, TSESTree, } from "@typescript-eslint/utils"; const createRule = ESLintUtils.RuleCreator(() => `https://github.com/masstronaut/typesafe-ts/blob/main/src/optional/readme.md`); /** * ESLint rule that enforces Optional usage patterns instead of nullable returns and direct nullable function calls. * * This rule: * - Disallows functions returning `T | null` or `T | undefined` * - Suggests using `optional.from_nullable()` for direct function calls that return nullable types * - Suggests using `optional.from()` for complex expressions that evaluate to nullable values * - Provides auto-fix suggestions where possible */ export const enforceOptionalUsage = createRule({ name: "enforce-optional-usage", meta: { type: "suggestion", docs: { description: "Enforce Optional monad usage instead of nullable returns and direct nullable calls", }, fixable: "code", schema: [ { type: "object", properties: { allowExceptions: { type: "array", items: { type: "string" }, description: "Function names or patterns to exclude from the rule", }, autoFix: { type: "boolean", description: "Enable automatic fixes", default: true, }, }, additionalProperties: false, }, ], messages: { noNullableReturn: "Functions should return Optional<{{type}}> instead of {{type}} | null | undefined. Change the return type and update return statements to use optional.some(value) or optional.none().", useOptionalFromNullable: "Function calls and other expressions that evaluate to nullable values should be wrapped in optional.from_nullable().", noNullableUnion: "Union types with null/undefined should use Optional<{{type}}> instead of {{type}} | null | undefined. Change the type annotation and initialize with optional.some(value) or optional.none().", }, }, defaultOptions: [{ allowExceptions: [], autoFix: true }], create(context, [options]) { const { allowExceptions = [], autoFix = true } = options; function isNullableUnion(node) { // Check for standalone null or undefined types if (node.typeAnnotation.type === AST_NODE_TYPES.TSNullKeyword || node.typeAnnotation.type === AST_NODE_TYPES.TSUndefinedKeyword) { return true; } // Check for union types containing null or undefined if (node.typeAnnotation.type !== AST_NODE_TYPES.TSUnionType) return false; const union = node.typeAnnotation; return union.types.some((type) => type.type === AST_NODE_TYPES.TSNullKeyword || type.type === AST_NODE_TYPES.TSUndefinedKeyword); } function getNonNullableType(typeAnnotation) { // Handle standalone null/undefined types if (typeAnnotation.typeAnnotation.type !== AST_NODE_TYPES.TSUnionType) { return "T"; // Fallback for other types } const union = typeAnnotation.typeAnnotation; const nonNullTypes = union.types.filter((type) => type.type !== AST_NODE_TYPES.TSNullKeyword && type.type !== AST_NODE_TYPES.TSUndefinedKeyword); if (nonNullTypes.length === 1) { const type = nonNullTypes[0]; if (type && type.type === AST_NODE_TYPES.TSTypeReference && "typeName" in type && type.typeName && type.typeName.type === AST_NODE_TYPES.Identifier) { return type.typeName.name; } if (type && type.type === AST_NODE_TYPES.TSStringKeyword) return "string"; if (type && type.type === AST_NODE_TYPES.TSNumberKeyword) return "number"; if (type && type.type === AST_NODE_TYPES.TSBooleanKeyword) return "boolean"; } return "T"; } function isExceptionFunction(name) { return allowExceptions.some((exception) => { if (exception.includes("*")) { const pattern = new RegExp(exception.replace(/\*/g, ".*")); return pattern.test(name); } return exception === name; }); } function isInsideOptionalFrom(node) { let parent = node.parent; while (parent) { if (parent.type === AST_NODE_TYPES.CallExpression && parent.callee.type === AST_NODE_TYPES.MemberExpression && parent.callee.object.type === AST_NODE_TYPES.Identifier && parent.callee.object.name === "optional" && parent.callee.property.type === AST_NODE_TYPES.Identifier && (parent.callee.property.name === "from" || parent.callee.property.name === "from_async" || parent.callee.property.name === "from_nullable")) { return true; } parent = parent.parent; } return false; } // Helper to collect return statements using ESLint's visitor pattern function collectReturnStatements(functionNode) { const returnStatements = []; // Use context.sourceCode.visitorKeys for type-safe traversal function traverse(node) { if (node.type === AST_NODE_TYPES.ReturnStatement) { returnStatements.push(node); return; // Don't traverse into the return expression } // Don't traverse into nested functions if (node !== functionNode && (node.type === AST_NODE_TYPES.FunctionDeclaration || node.type === AST_NODE_TYPES.FunctionExpression || node.type === AST_NODE_TYPES.ArrowFunctionExpression)) { return; } // Traverse child nodes using ESLint's visitor keys const visitorKeys = context.sourceCode.visitorKeys[node.type] || []; for (const key of visitorKeys) { const child = node[key]; if (Array.isArray(child)) { child.forEach((item) => { if (item && typeof item === "object" && "type" in item) { traverse(item); } }); } else if (child && typeof child === "object" && "type" in child) { traverse(child); } } } if (functionNode.body) { traverse(functionNode.body); } return returnStatements; } function hasNullableReturnStatements(functionNode) { // Special handling for arrow functions with implicit returns if (functionNode.type === AST_NODE_TYPES.ArrowFunctionExpression && functionNode.body && functionNode.body.type !== AST_NODE_TYPES.BlockStatement) { // Arrow function with implicit return - check the body directly return containsNullableValue(functionNode.body); } const returnStatements = collectReturnStatements(functionNode); // Analyze return patterns to distinguish void functions from value-returning functions if (returnStatements.length === 0) { return false; // No returns, effectively void } const nakedReturns = returnStatements.filter((stmt) => !stmt.argument); const valueReturns = returnStatements.filter((stmt) => stmt.argument); // If ALL returns are naked returns, treat as void function if (nakedReturns.length > 0 && valueReturns.length === 0) { return false; // Pure void function pattern } // For functions with value returns, check for nullable values const hasNullableValueReturns = valueReturns.some((returnStmt) => { return containsNullableValue(returnStmt.argument); }); // Flag if there are nullable value returns OR mixed return patterns return (hasNullableValueReturns || (nakedReturns.length > 0 && valueReturns.length > 0)); } function containsNullableValue(node) { if (!node) return false; // Check for literal null if (node.type === AST_NODE_TYPES.Literal && node.value === null) { return true; } // Check for undefined identifier if (node.type === AST_NODE_TYPES.Identifier && node.name === "undefined") { return true; } // Check for conditional expressions (ternary operator) if (node.type === AST_NODE_TYPES.ConditionalExpression) { return (containsNullableValue(node.consequent) || containsNullableValue(node.alternate)); } // Check for logical expressions (&&, ||) if (node.type === AST_NODE_TYPES.LogicalExpression) { return (containsNullableValue(node.left) || containsNullableValue(node.right)); } return false; } function inferNonNullableTypeFromReturns(functionNode) { // Special handling for arrow functions with implicit returns if (functionNode.type === AST_NODE_TYPES.ArrowFunctionExpression && functionNode.body && functionNode.body.type !== AST_NODE_TYPES.BlockStatement) { // Arrow function with implicit return - analyze the body directly const nonNullableType = extractNonNullableType(functionNode.body); if (nonNullableType !== "T") { return nonNullableType; } } const returnStatements = collectReturnStatements(functionNode); // Try to infer the non-nullable type from return statements for (const returnStmt of returnStatements) { if (!returnStmt.argument) continue; const nonNullableType = extractNonNullableType(returnStmt.argument); if (nonNullableType !== "T") { return nonNullableType; } } return "T"; } function extractNonNullableType(node) { if (!node) return "T"; // Skip null and undefined if ((node.type === AST_NODE_TYPES.Literal && node.value === null) || (node.type === AST_NODE_TYPES.Identifier && node.name === "undefined")) { return "T"; } // For literal strings if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") { return "string"; } // For literal numbers if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "number") { return "number"; } // For literal booleans if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "boolean") { return "boolean"; } // For conditional expressions, try both branches if (node.type === AST_NODE_TYPES.ConditionalExpression) { const consequentType = extractNonNullableType(node.consequent); if (consequentType !== "T") return consequentType; const alternateType = extractNonNullableType(node.alternate); if (alternateType !== "T") return alternateType; } // For logical expressions if (node.type === AST_NODE_TYPES.LogicalExpression) { const leftType = extractNonNullableType(node.left); if (leftType !== "T") return leftType; const rightType = extractNonNullableType(node.right); if (rightType !== "T") return rightType; } return "T"; } // Helper function to detect String.prototype.match calls function isStringMatchCall(node) { if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false; // String.match() typically takes a RegExp or string as first argument // This is a heuristic to identify string match vs other match methods if (node.arguments.length === 0) return false; const firstArg = node.arguments[0]; if (!firstArg) return false; return ( // RegExp literal: /pattern/ (firstArg.type === AST_NODE_TYPES.Literal && "regex" in firstArg && firstArg.regex !== undefined) || // String literal: "pattern" (firstArg.type === AST_NODE_TYPES.Literal && "value" in firstArg && typeof firstArg.value === "string") || // Template literal: `pattern` firstArg.type === AST_NODE_TYPES.TemplateLiteral || // Variable that might be a RegExp/string (less certain but common) firstArg.type === AST_NODE_TYPES.Identifier); } return { // Check function return types (includes methods via FunctionExpression) "FunctionDeclaration, FunctionExpression, ArrowFunctionExpression"(node) { // Skip if this function is inside an optional.from() or optional.from_async() call if (isInsideOptionalFrom(node)) { return; } // Check explicit return type annotations const hasExplicitNullableReturn = node.returnType && isNullableUnion(node.returnType); // Check if function returns null/undefined without explicit annotation const hasImplicitNullableReturn = !node.returnType && hasNullableReturnStatements(node); if (!hasExplicitNullableReturn && !hasImplicitNullableReturn) return; // Skip if this is a method function (handled by parent MethodDefinition selector) if (node.type === AST_NODE_TYPES.FunctionExpression && node.parent?.type === AST_NODE_TYPES.MethodDefinition) { return; } const functionName = (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id?.name) || (node.parent?.type === AST_NODE_TYPES.VariableDeclarator && node.parent.id.type === AST_NODE_TYPES.Identifier && node.parent.id.name) || "anonymous"; if (isExceptionFunction(functionName)) return; const baseType = node.returnType ? getNonNullableType(node.returnType) : inferNonNullableTypeFromReturns(node); context.report({ node: node.returnType || node.id || node, messageId: "noNullableReturn", data: { type: baseType }, // Disable auto-fix for function return types as it requires // updating all return statements within the function body fix: null, }); }, // Check method return types MethodDefinition(node) { if (node.value.type !== AST_NODE_TYPES.FunctionExpression || !node.value.returnType) return; if (!isNullableUnion(node.value.returnType)) return; const methodName = node.key.type === AST_NODE_TYPES.Identifier ? node.key.name : "method"; if (isExceptionFunction(methodName)) return; const baseType = getNonNullableType(node.value.returnType); context.report({ node: node.value.returnType, messageId: "noNullableReturn", data: { type: baseType }, // Disable auto-fix for method return types as it requires // updating all return statements within the method body fix: null, }); }, // Check variable declarations with nullable union types "VariableDeclarator[id.typeAnnotation]"(node) { if (node.id.type !== AST_NODE_TYPES.Identifier || !node.id.typeAnnotation) return; if (!isNullableUnion(node.id.typeAnnotation)) return; const baseType = getNonNullableType(node.id.typeAnnotation); context.report({ node: node.id.typeAnnotation, messageId: "noNullableUnion", data: { type: baseType }, // Disable auto-fix for variable declarations as it requires // updating the variable initialization value fix: null, }); }, // Check calls to functions that might return null/undefined CallExpression(node) { // Skip if this is already an optional.from(), optional.from_async(), or optional.from_nullable() call if (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.object.type === AST_NODE_TYPES.Identifier && node.callee.object.name === "optional" && node.callee.property.type === AST_NODE_TYPES.Identifier && (node.callee.property.name === "from" || node.callee.property.name === "from_async" || node.callee.property.name === "from_nullable")) { return; } // Skip if this call is inside an optional.from() or optional.from_async() arrow function // or if it's a direct argument to optional.from_nullable() let parent = node.parent; while (parent) { // Check for optional.from_nullable(thisCall) if (parent.type === AST_NODE_TYPES.CallExpression && parent.callee.type === AST_NODE_TYPES.MemberExpression && parent.callee.object.type === AST_NODE_TYPES.Identifier && parent.callee.object.name === "optional" && parent.callee.property.type === AST_NODE_TYPES.Identifier && parent.callee.property.name === "from_nullable") { return; } // Check for optional.from(() => thisCall) or optional.from_async(() => thisCall) if (parent.type === AST_NODE_TYPES.ArrowFunctionExpression && parent.parent?.type === AST_NODE_TYPES.CallExpression && parent.parent.callee.type === AST_NODE_TYPES.MemberExpression && parent.parent.callee.object.type === AST_NODE_TYPES.Identifier && parent.parent.callee.object.name === "optional" && parent.parent.callee.property.type === AST_NODE_TYPES.Identifier && (parent.parent.callee.property.name === "from" || parent.parent.callee.property.name === "from_async")) { return; } if (!parent.parent) break; parent = parent.parent; } // Check for common nullable-returning functions let functionName = ""; if (node.callee.type === AST_NODE_TYPES.Identifier) { functionName = node.callee.name; } else if (node.callee.type === AST_NODE_TYPES.MemberExpression && node.callee.property.type === AST_NODE_TYPES.Identifier) { functionName = node.callee.property.name; } // Common DOM/JS APIs that return null const nullableAPIs = [ "getElementById", "querySelector", "getElementsByClassName", "getElementsByTagName", "find", "pop", "shift", ]; // Special handling for 'match' - only flag String.prototype.match, not other match methods const isMatchCall = functionName === "match"; const isStringMatch = isMatchCall && isStringMatchCall(node); if (nullableAPIs.includes(functionName) || isStringMatch) { context.report({ node, messageId: "useOptionalFromNullable", fix: autoFix ? (fixer) => { const sourceCode = context.sourceCode; const callText = sourceCode.getText(node); return fixer.replaceText(node, `optional.from_nullable(${callText})`); } : null, }); } }, }; }, }); export default enforceOptionalUsage; //# sourceMappingURL=lint.js.map