// https://github.com/typescript-eslint/typescript-eslint/blob/75c128856b1ce05a4fec799bfa6de03b3dab03d0/packages/eslint-plugin/src/rules/restrict-template-expressions.ts import { getConstrainedTypeAtLocation, getTypeName, isTypeAnyType, isTypeFlagSet, isTypeNeverType, } from '@typescript-eslint/type-utils'; import { AST_NODE_TYPES, ESLintUtils, ParserServicesWithTypeInformation, type TSESTree, } from '@typescript-eslint/utils'; import { getParserServices } from '@typescript-eslint/utils/eslint-utils'; import * as ts from 'typescript'; const createRule = ESLintUtils.RuleCreator( name => `https://typescript-eslint.io/rules/${name}`, ); interface Option { allow: string[]; } const defaultOption: Option = { allow: ['any', 'boolean', 'null', 'undefined', 'number', 'RegExp', 'URLSearchParams'], }; type MessageId = 'invalidType'; export default createRule({ name: 'restrict-template-expressions', meta: { type: 'problem', docs: { description: 'Enforce template literal expressions to be of `string` type', }, messages: { invalidType: 'Invalid type "{{type}}" of template literal expression.', }, schema: [ { type: 'object', additionalProperties: false, properties: { allow: { type: 'array', items: { type: 'string', }, description: 'Allow specific types', uniqueItems: true, }, }, }, ], }, defaultOptions: [defaultOption], create(context, [options]) { let services: ParserServicesWithTypeInformation | undefined; try { services = getParserServices(context); } catch (error) { console.error(error); } if (!services?.program) return {}; const checker = services.program.getTypeChecker(); const allowed = new Set(options.allow); const { StringLike, NumberLike, BigIntLike, BooleanLike, Null, Undefined } = ts.TypeFlags; function isUnderlyingTypePrimitive(type: ts.Type): boolean { return ( isTypeFlagSet(type, StringLike) || (allowed.has('number') && isTypeFlagSet(type, NumberLike | BigIntLike)) || (allowed.has('boolean') && isTypeFlagSet(type, BooleanLike)) || (allowed.has('any') && isTypeAnyType(type)) || allowed.has(getTypeName(checker, type)) || (allowed.has('null') && isTypeFlagSet(type, Null)) || (allowed.has('undefined') && isTypeFlagSet(type, Undefined)) || (allowed.has('never') && isTypeNeverType(type)) ); } return { TemplateLiteral(node: TSESTree.TemplateLiteral): void { // don't check tagged template literals if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) { return; } for (const expression of node.expressions) { const expressionType = getConstrainedTypeAtLocation(services, expression); if ( !isInnerUnionOrIntersectionConformingTo( expressionType, isUnderlyingTypePrimitive, ) ) { context.report({ node: expression, messageId: 'invalidType', data: { type: checker.typeToString(expressionType) }, }); } } }, }; function isInnerUnionOrIntersectionConformingTo( type: ts.Type, predicate: (underlyingType: ts.Type) => boolean, ): boolean { return rec(type); function rec(innerType: ts.Type): boolean { if (innerType.isUnion()) { return innerType.types.every(rec); } if (innerType.isIntersection()) { return innerType.types.some(rec); } return predicate(innerType); } } }, });