eslint-rules/src/custom/restrict-template-expressions.ts
2024-10-16 00:29:26 -04:00

134 lines
3.7 KiB
TypeScript

// 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<Option[], MessageId>({
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);
}
}
},
});