diff --git a/docs/rules/no-type-assertion-as.md b/docs/rules/no-type-assertion-as.md new file mode 100644 index 0000000..0c784e2 --- /dev/null +++ b/docs/rules/no-type-assertion-as.md @@ -0,0 +1,15 @@ +# Disallow the use of `as` type assertions and we suggest using `satisfies` instead to ensure that an expression conforms to a specific type without changing the type of the expression itself + +## Fail + +```ts +const events = request.body as TestEvent[]; +const complexEvent = request.body as { type: string; payload: any }; +``` + +## Pass + +```ts +const newEvent = request.body satisfies AnotherEventType; +const complexEvent = request.body satisfies { type: string; payload: any }; +``` diff --git a/package-lock.json b/package-lock.json index a6fa5db..78da8fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@checkdigit/eslint-plugin", - "version": "7.14.0", + "version": "7.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@checkdigit/eslint-plugin", - "version": "7.14.0", + "version": "7.15.0", "license": "MIT", "dependencies": { "@typescript-eslint/type-utils": "^8.23.0", diff --git a/package.json b/package.json index e9bdc6e..d15682b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@checkdigit/eslint-plugin", - "version": "7.14.0", + "version": "7.15.0", "description": "Check Digit eslint plugins", "keywords": [ "eslint", diff --git a/src/index.ts b/src/index.ts index 0867e1a..a2e0243 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import noEnum from './no-enum.ts'; import noSideEffects from './no-side-effects.ts'; import noRandomV4UUID from './no-random-v4-uuid.ts'; import noTestImport from './no-test-import.ts'; +import noTypeAssertionAs from './no-type-assertion-as.ts'; import noUtil from './no-util.ts'; import noUuid from './no-uuid.ts'; import noWallabyComment from './no-wallaby-comment.ts'; @@ -56,6 +57,7 @@ const rules: Record = { 'no-test-import': noTestImport, 'no-wallaby-comment': noWallabyComment, 'no-side-effects': noSideEffects, + 'no-type-assertion-as': noTypeAssertionAs, 'regular-expression-comment': regexComment, 'require-assert-predicate-rejects-throws': requireAssertPredicateRejectsThrows, 'object-literal-response': objectLiteralResponse, @@ -94,6 +96,7 @@ const configs: Record = { '@checkdigit/require-ts-extension-imports-exports': 'error', '@checkdigit/no-wallaby-comment': 'error', '@checkdigit/no-side-effects': 'error', + '@checkdigit/no-type-assertion-as': 'error', '@checkdigit/regular-expression-comment': 'error', '@checkdigit/require-assert-predicate-rejects-throws': 'error', '@checkdigit/object-literal-response': 'error', @@ -129,6 +132,7 @@ const configs: Record = { '@checkdigit/require-ts-extension-imports-exports': 'error', '@checkdigit/no-wallaby-comment': 'off', '@checkdigit/no-side-effects': 'error', + '@checkdigit/no-type-assertion-as': 'error', '@checkdigit/regular-expression-comment': 'error', '@checkdigit/require-assert-predicate-rejects-throws': 'error', '@checkdigit/object-literal-response': 'error', diff --git a/src/no-type-assertion-as.spec.ts b/src/no-type-assertion-as.spec.ts new file mode 100644 index 0000000..2b30c30 --- /dev/null +++ b/src/no-type-assertion-as.spec.ts @@ -0,0 +1,130 @@ +// no-type-assertion-as.spec.ts + +import rule, { ruleId } from './no-type-assertion-as'; +import createTester from './ts-tester.test'; + +createTester().run(ruleId, rule, { + valid: [ + { + name: 'Valid case without type assertion', + code: `const event = request.body;`, + }, + { + name: 'Valid case with type annotation', + code: `const event: TestEvent = request.body;`, + }, + { + name: 'Valid case with satisfies', + code: `const event = request.body satisfies TestEvent;`, + }, + { + name: 'Valid case with satisfies and different type', + code: `const newEvent = request.body satisfies AnotherEventType;`, + }, + { + name: 'Valid case with satisfies and complex type', + code: `const complexEvent = request.body satisfies { type: string; payload: any };`, + }, + { + name: 'Valid case with satisfies and array type', + code: `const events = request.body satisfies TestEvent[];`, + }, + ], + invalid: [ + { + name: 'Invalid case with as type assertion', + code: `const event = request.body as TestEvent;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion and different type', + code: `const newEvent = request.body as AnotherEventType;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion and complex type', + code: `const complexEvent = request.body as { type: string; payload: any };`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion and array type', + code: `const events = request.body as TestEvent[];`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion and union type', + code: `const result = response as SuccessResponse | ErrorResponse;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion and intersection type', + code: `const result = response as SuccessResponse & ErrorResponse;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as unknown type assertion', + code: `const value = someValue as unknown;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as unknown as type assertion', + code: `const value = someValue as unknown as SomeType;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as unknown as part of union type assertion', + code: `const result = response as unknown | SomeType;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as unknown as part of intersection type assertion', + code: `const result = response as unknown & SomeType`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with nested as type assertion', + code: `const nested = (request.body as TestEvent) as AnotherEventType;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in function parameter', + code: `function handleEvent(event: any) { const typedEvent = event as TestEvent; }`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in return statement', + code: `function getEvent(): TestEvent { return request.body as TestEvent; }`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in arrow function', + code: `const getEvent = (): TestEvent => request.body as TestEvent;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in class property', + code: `class EventProcessor { private event = request.body as TestEvent; }`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in conditional expression', + code: `const event = condition ? (request.body as TestEvent) : (request.body as AnotherEventType);`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }, { messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in template literal', + code: `const event = \`\${request.body as TestEvent}\`;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with as type assertion in array destructuring', + code: `const [event] = [request.body as TestEvent];`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + { + name: 'Invalid case with double as type assertion (unknown to specific type)', + code: `const value = someValue as unknown as SomeType;`, + errors: [{ messageId: 'NO_AS_TYPE_ASSERTION' }], + }, + ], +}); diff --git a/src/no-type-assertion-as.ts b/src/no-type-assertion-as.ts new file mode 100644 index 0000000..576f0a2 --- /dev/null +++ b/src/no-type-assertion-as.ts @@ -0,0 +1,57 @@ +// no-type-assertion-as.ts + +/* + * Copyright (c) 2021-2025 Check Digit, LLC + * + * This code is licensed under the MIT license (see LICENSE.txt for details). + */ + +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; + +export const ruleId = 'no-as-type-assertion'; +const NO_AS_TYPE_ASSERTION = 'NO_AS_TYPE_ASSERTION'; + +const createRule = ESLintUtils.RuleCreator((name) => name); + +const rule: ESLintUtils.RuleModule = createRule({ + name: ruleId, + meta: { + type: 'problem', + docs: { + description: 'Disallow the use of `as` type assertions and suggest using `satisfies` instead', + }, + schema: [], + messages: { + [NO_AS_TYPE_ASSERTION]: 'Avoid using `as` type assertions. Use `satisfies` instead.', + }, + }, + defaultOptions: [], + create(context) { + const parserServices = ESLintUtils.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + return { + TSAsExpression(node: TSESTree.TSAsExpression) { + if (node.parent.type === AST_NODE_TYPES.TSAsExpression) { + return; + } + + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.expression); + const originalType = checker.getTypeAtLocation(tsNode); + const targetType = checker.getTypeAtLocation(parserServices.esTreeNodeToTSNodeMap.get(node.typeAnnotation)); + + // Ensure the types are not assignable + if (!checker.isTypeAssignableTo(originalType, targetType)) { + return; + } + + context.report({ + node, + messageId: NO_AS_TYPE_ASSERTION, + }); + }, + }; + }, +}); + +export default rule;