diff --git a/packages/eslint-plugin/docs/rules/restrict-template-expressions.mdx b/packages/eslint-plugin/docs/rules/restrict-template-expressions.mdx index 2d6b03041224..60ea75a7c65e 100644 --- a/packages/eslint-plugin/docs/rules/restrict-template-expressions.mdx +++ b/packages/eslint-plugin/docs/rules/restrict-template-expressions.mdx @@ -85,7 +85,7 @@ const msg2 = `arg = ${arg || 'not truthy'}`; ### `allowAny` -Whether to `any` typed values in template expressions. +Whether to allow `any` typed values in template expressions. Examples of additional **correct** code for this rule with `{ allowAny: true }`: @@ -124,7 +124,7 @@ const msg1 = `arg = ${arg}`; ### `allowNever` -Whether to `never` typed values in template expressions. +Whether to allow `never` typed values in template expressions. Examples of additional **correct** code for this rule with `{ allowNever: true }`: @@ -135,7 +135,7 @@ const msg1 = typeof arg === 'string' ? arg : `arg = ${arg}`; ### `allowArray` -Whether to `Array` typed values in template expressions. +Whether to allow `Array` typed values in template expressions. Examples of additional **correct** code for this rule with `{ allowArray: true }`: @@ -144,6 +144,19 @@ const arg = ['foo', 'bar']; const msg1 = `arg = ${arg}`; ``` +### `allow` + +Whether to allow additional types in template expressions. + +This option takes the shared [`TypeOrValueSpecifier` format](/packages/type-utils/type-or-value-specifier). + +Examples of additional **correct** code for this rule with the default option `{ allow: [{ from: 'lib', name: 'Error' }, { from: 'lib', name: 'URL' }, { from: 'lib', name: 'URLSearchParams' }] }`: + +```ts showPlaygroundButton +const error = new Error(); +const msg1 = `arg = ${error}`; +``` + ## When Not To Use It If you're not worried about incorrectly stringifying non-string values in template literals, then you likely don't need this rule. diff --git a/packages/eslint-plugin/src/rules/no-floating-promises.ts b/packages/eslint-plugin/src/rules/no-floating-promises.ts index 3b0265e1718f..dcc965556c55 100644 --- a/packages/eslint-plugin/src/rules/no-floating-promises.ts +++ b/packages/eslint-plugin/src/rules/no-floating-promises.ts @@ -12,7 +12,7 @@ import { OperatorPrecedence, readonlynessOptionsDefaults, readonlynessOptionsSchema, - typeMatchesSpecifier, + typeMatchesSomeSpecifier, } from '../util'; type Options = [ @@ -238,8 +238,10 @@ export default createRule({ const type = services.getTypeAtLocation(node.callee); - return allowForKnownSafeCalls.some(allowedType => - typeMatchesSpecifier(type, allowedType, services.program), + return typeMatchesSomeSpecifier( + type, + allowForKnownSafeCalls, + services.program, ); } @@ -407,8 +409,10 @@ export default createRule({ // The highest priority is to allow anything allowlisted if ( - allowForKnownSafePromises.some(allowedType => - typeMatchesSpecifier(type, allowedType, services.program), + typeMatchesSomeSpecifier( + type, + allowForKnownSafePromises, + services.program, ) ) { return false; diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index 5aa90084ac63..54b94aa0b193 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -1,8 +1,13 @@ +import { + typeMatchesSomeSpecifier, + typeOrValueSpecifiersSchema, +} from '@typescript-eslint/type-utils'; import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import type { Type, TypeChecker } from 'typescript'; import { TypeFlags } from 'typescript'; +import type { TypeOrValueSpecifier } from '../util'; import { createRule, getConstrainedTypeAtLocation, @@ -43,14 +48,16 @@ const optionTesters = ( (type, checker): boolean => getTypeName(checker, type) === 'RegExp', ], ['Never', isTypeNeverType], - ] satisfies [string, OptionTester][] + ] as const satisfies [string, OptionTester][] ).map(([type, tester]) => ({ type, option: `allow${type}` as const, tester, })); type Options = [ - { [Type in (typeof optionTesters)[number]['option']]?: boolean }, + { [Type in (typeof optionTesters)[number]['option']]?: boolean } & { + allow?: TypeOrValueSpecifier[]; + }, ]; type MessageId = 'invalidType'; @@ -84,15 +91,21 @@ export default createRule({ { type: 'object', additionalProperties: false, - properties: Object.fromEntries( - optionTesters.map(({ option, type }) => [ - option, - { - description: `Whether to allow \`${type.toLowerCase()}\` typed values in template expressions.`, - type: 'boolean', - }, - ]), - ), + properties: { + ...Object.fromEntries( + optionTesters.map(({ option, type }) => [ + option, + { + description: `Whether to allow \`${type.toLowerCase()}\` typed values in template expressions.`, + type: 'boolean', + }, + ]), + ), + allow: { + description: `Types to allow in template expressions.`, + ...typeOrValueSpecifiersSchema, + }, + }, }, ], }, @@ -103,11 +116,13 @@ export default createRule({ allowNullish: true, allowNumber: true, allowRegExp: true, + allow: [{ from: 'lib', name: ['Error', 'URL', 'URLSearchParams'] }], }, ], - create(context, [options]) { + create(context, [{ allow, ...options }]) { const services = getParserServices(context); - const checker = services.program.getTypeChecker(); + const { program } = services; + const checker = program.getTypeChecker(); const enabledOptionTesters = optionTesters.filter( ({ option }) => options[option], ); @@ -147,6 +162,7 @@ export default createRule({ return ( isTypeFlagSet(innerType, TypeFlags.StringLike) || + typeMatchesSomeSpecifier(innerType, allow, program) || enabledOptionTesters.some(({ tester }) => tester(innerType, checker, recursivelyCheckType), ) diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/restrict-template-expressions.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/restrict-template-expressions.shot index 06f982288ebc..18fcf8c5ba31 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/restrict-template-expressions.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/restrict-template-expressions.shot @@ -91,3 +91,11 @@ const arg = ['foo', 'bar']; const msg1 = \`arg = \${arg}\`; " `; + +exports[`Validating rule docs restrict-template-expressions.mdx code examples ESLint output 11`] = ` +" + +const error = new Error(); +const msg1 = \`arg = \${error}\`; +" +`; diff --git a/packages/eslint-plugin/tests/docs.test.ts b/packages/eslint-plugin/tests/docs.test.ts index ee518640efed..fd8405d62833 100644 --- a/packages/eslint-plugin/tests/docs.test.ts +++ b/packages/eslint-plugin/tests/docs.test.ts @@ -153,6 +153,7 @@ describe('Validating rule docs', () => { const ignoredFiles = new Set([ 'README.md', 'TEMPLATE.md', + 'shared', // These rule docs were left behind on purpose for legacy reasons. See the // comments in the files for more information. 'ban-types.md', diff --git a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts index c60c2be4b0aa..e7d6872eaf85 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -344,6 +344,12 @@ ruleTester.run('restrict-template-expressions', rule, { } `, }, + // allow + { + options: [{ allow: [{ from: 'lib', name: 'Promise' }] }], + code: 'const msg = `arg = ${Promise.resolve()}`;', + }, + 'const msg = `arg = ${new Error()}`;', 'const msg = `arg = ${false}`;', 'const msg = `arg = ${null}`;', 'const msg = `arg = ${undefined}`;', @@ -422,6 +428,15 @@ ruleTester.run('restrict-template-expressions', rule, { ], options: [{ allowNullish: false, allowArray: true }], }, + { + code: 'const msg = `arg = ${Promise.resolve()}`;', + errors: [{ messageId: 'invalidType' }], + }, + { + code: 'const msg = `arg = ${new Error()}`;', + options: [{ allow: [] }], + errors: [{ messageId: 'invalidType' }], + }, { code: ` declare const arg: [number | undefined, string]; diff --git a/packages/eslint-plugin/tests/schema-snapshots/restrict-template-expressions.shot b/packages/eslint-plugin/tests/schema-snapshots/restrict-template-expressions.shot index 1a4d828e764e..9a2625cd73c3 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/restrict-template-expressions.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/restrict-template-expressions.shot @@ -8,6 +8,101 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos { "additionalProperties": false, "properties": { + "allow": { + "description": "Types to allow in template expressions.", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "enum": ["file"], + "type": "string" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + }, + "path": { + "type": "string" + } + }, + "required": ["from", "name"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "enum": ["lib"], + "type": "string" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + } + }, + "required": ["from", "name"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "enum": ["package"], + "type": "string" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + }, + "package": { + "type": "string" + } + }, + "required": ["from", "name", "package"], + "type": "object" + } + ] + }, + "type": "array" + }, "allowAny": { "description": "Whether to allow \`any\` typed values in template expressions.", "type": "boolean" @@ -46,6 +141,24 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos type Options = [ { + /** Types to allow in template expressions. */ + allow?: ( + | { + from: 'file'; + name: [string, ...string[]] | string; + path?: string; + } + | { + from: 'lib'; + name: [string, ...string[]] | string; + } + | { + from: 'package'; + name: [string, ...string[]] | string; + package: string; + } + | string + )[]; /** Whether to allow \`any\` typed values in template expressions. */ allowAny?: boolean; /** Whether to allow \`array\` typed values in template expressions. */ diff --git a/packages/type-utils/src/TypeOrValueSpecifier.ts b/packages/type-utils/src/TypeOrValueSpecifier.ts index 422e83769208..d9c50f315b91 100644 --- a/packages/type-utils/src/TypeOrValueSpecifier.ts +++ b/packages/type-utils/src/TypeOrValueSpecifier.ts @@ -66,97 +66,100 @@ export type TypeOrValueSpecifier = | PackageSpecifier | string; -export const typeOrValueSpecifierSchema: JSONSchema4 = { - oneOf: [ - { - type: 'string', - }, - { - type: 'object', - additionalProperties: false, - properties: { - from: { - type: 'string', - enum: ['file'], - }, - name: { - oneOf: [ - { - type: 'string', - }, - { - type: 'array', - minItems: 1, - uniqueItems: true, - items: { +export const typeOrValueSpecifiersSchema = { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'object', + additionalProperties: false, + properties: { + from: { + type: 'string', + enum: ['file'], + }, + name: { + oneOf: [ + { type: 'string', }, - }, - ], - }, - path: { - type: 'string', + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + path: { + type: 'string', + }, }, + required: ['from', 'name'], }, - required: ['from', 'name'], - }, - { - type: 'object', - additionalProperties: false, - properties: { - from: { - type: 'string', - enum: ['lib'], - }, - name: { - oneOf: [ - { - type: 'string', - }, - { - type: 'array', - minItems: 1, - uniqueItems: true, - items: { + { + type: 'object', + additionalProperties: false, + properties: { + from: { + type: 'string', + enum: ['lib'], + }, + name: { + oneOf: [ + { type: 'string', }, - }, - ], + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, }, + required: ['from', 'name'], }, - required: ['from', 'name'], - }, - { - type: 'object', - additionalProperties: false, - properties: { - from: { - type: 'string', - enum: ['package'], - }, - name: { - oneOf: [ - { - type: 'string', - }, - { - type: 'array', - minItems: 1, - uniqueItems: true, - items: { + { + type: 'object', + additionalProperties: false, + properties: { + from: { + type: 'string', + enum: ['package'], + }, + name: { + oneOf: [ + { type: 'string', }, - }, - ], - }, - package: { - type: 'string', + { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { + type: 'string', + }, + }, + ], + }, + package: { + type: 'string', + }, }, + required: ['from', 'name', 'package'], }, - required: ['from', 'name', 'package'], - }, - ], -}; + ], + }, +} as const satisfies JSONSchema4; export function typeMatchesSpecifier( type: ts.Type, @@ -191,3 +194,10 @@ export function typeMatchesSpecifier( ); } } + +export const typeMatchesSomeSpecifier = ( + type: ts.Type, + specifiers: TypeOrValueSpecifier[] = [], + program: ts.Program, +): boolean => + specifiers.some(specifier => typeMatchesSpecifier(type, specifier, program)); diff --git a/packages/type-utils/src/isTypeReadonly.ts b/packages/type-utils/src/isTypeReadonly.ts index c8f89ca2fd12..cc44a0f5f252 100644 --- a/packages/type-utils/src/isTypeReadonly.ts +++ b/packages/type-utils/src/isTypeReadonly.ts @@ -6,8 +6,8 @@ import * as ts from 'typescript'; import { getTypeOfPropertyOfType } from './propertyTypes'; import type { TypeOrValueSpecifier } from './TypeOrValueSpecifier'; import { - typeMatchesSpecifier, - typeOrValueSpecifierSchema, + typeMatchesSomeSpecifier, + typeOrValueSpecifiersSchema, } from './TypeOrValueSpecifier'; const enum Readonlyness { @@ -31,10 +31,7 @@ export const readonlynessOptionsSchema = { treatMethodsAsReadonly: { type: 'boolean', }, - allow: { - type: 'array', - items: typeOrValueSpecifierSchema, - }, + allow: typeOrValueSpecifiersSchema, }, } satisfies JSONSchema4; @@ -232,11 +229,7 @@ function isTypeReadonlyRecurser( const checker = program.getTypeChecker(); seenTypes.add(type); - if ( - options.allow?.some(specifier => - typeMatchesSpecifier(type, specifier, program), - ) - ) { + if (typeMatchesSomeSpecifier(type, options.allow, program)) { return Readonlyness.Readonly; } diff --git a/packages/type-utils/tests/TypeOrValueSpecifier.test.ts b/packages/type-utils/tests/TypeOrValueSpecifier.test.ts index fd676a289939..b38b84cbb867 100644 --- a/packages/type-utils/tests/TypeOrValueSpecifier.test.ts +++ b/packages/type-utils/tests/TypeOrValueSpecifier.test.ts @@ -4,23 +4,20 @@ import { parseForESLint } from '@typescript-eslint/parser'; import type { TSESTree } from '@typescript-eslint/utils'; import Ajv from 'ajv'; -import type { TypeOrValueSpecifier } from '../src/TypeOrValueSpecifier'; -import { - typeMatchesSpecifier, - typeOrValueSpecifierSchema, -} from '../src/TypeOrValueSpecifier'; +import type { TypeOrValueSpecifier } from '../src'; +import { typeMatchesSpecifier, typeOrValueSpecifiersSchema } from '../src'; describe('TypeOrValueSpecifier', () => { describe('Schema', () => { const ajv = new Ajv(); - const validate = ajv.compile(typeOrValueSpecifierSchema); + const validate = ajv.compile(typeOrValueSpecifiersSchema); - function runTestPositive(data: unknown): void { - expect(validate(data)).toBe(true); + function runTestPositive(typeOrValueSpecifier: unknown): void { + expect(validate([typeOrValueSpecifier])).toBe(true); } - function runTestNegative(data: unknown): void { - expect(validate(data)).toBe(false); + function runTestNegative(typeOrValueSpecifier: unknown): void { + expect(validate([typeOrValueSpecifier])).toBe(false); } it.each([['MyType'], ['myValue'], ['any'], ['void'], ['never']])( @@ -448,7 +445,7 @@ describe('TypeOrValueSpecifier', () => { 'import type {Node as TsNode} from "typescript"; type Test = TsNode;', { from: 'package', name: 'TsNode', package: 'typescript' }, ], - ])("doesn't match a mismatched lib specifier: %s", runTestNegative); + ])("doesn't match a mismatched package specifier: %s", runTestNegative); it.each<[string, TypeOrValueSpecifier]>([ [