Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): [restrict-template-expressions] add support for intersection types #1803

Expand Up @@ -6,6 +6,9 @@ Examples of **correct** code:
const arg = 'foo';
const msg1 = `arg = ${arg}`;
const msg2 = `arg = ${arg || 'default'}`;

const stringWithKindProp: string & { _kind?: 'MyString' } = 'foo';
const msg3 = `stringWithKindProp = ${stringWithKindProp}`;
```

Examples of **incorrect** code:
Expand All @@ -28,6 +31,8 @@ type Options = {
allowNumber?: boolean;
// if true, also allow boolean type in template expressions
allowBoolean?: boolean;
// if true, also allow any in template expressions
allowAny?: boolean;
// if true, also allow null and undefined in template expressions
allowNullable?: boolean;
};
Expand Down
142 changes: 64 additions & 78 deletions packages/eslint-plugin/src/rules/restrict-template-expressions.ts
Expand Up @@ -7,10 +7,10 @@ import * as util from '../util';

type Options = [
{
allowNullable?: boolean;
allowNumber?: boolean;
allowBoolean?: boolean;
allowAny?: boolean;
allowNullable?: boolean;
},
];

Expand All @@ -33,10 +33,10 @@ export default util.createRule<Options, MessageId>({
{
type: 'object',
properties: {
allowAny: { type: 'boolean' },
allowNumber: { type: 'boolean' },
allowBoolean: { type: 'boolean' },
allowAny: { type: 'boolean' },
allowNullable: { type: 'boolean' },
allowNumber: { type: 'boolean' },
},
},
],
Expand All @@ -46,31 +46,40 @@ export default util.createRule<Options, MessageId>({
const service = util.getParserServices(context);
const typeChecker = service.program.getTypeChecker();

type BaseType =
| 'string'
| 'number'
| 'bigint'
| 'boolean'
| 'null'
| 'undefined'
| 'any'
| 'other';

const allowedTypes: BaseType[] = [
'string',
...(options.allowNumber ? (['number', 'bigint'] as const) : []),
...(options.allowBoolean ? (['boolean'] as const) : []),
...(options.allowNullable ? (['null', 'undefined'] as const) : []),
...(options.allowAny ? (['any'] as const) : []),
];

function isAllowedType(types: BaseType[]): boolean {
for (const type of types) {
if (!allowedTypes.includes(type)) {
return false;
}
function isUnderlyingTypePrimitive(type: ts.Type): boolean {
if (util.isTypeFlagSet(type, ts.TypeFlags.StringLike)) {
return true;
}

if (
options.allowNumber &&
util.isTypeFlagSet(
type,
ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike,
)
) {
return true;
}
return true;

if (
options.allowBoolean &&
util.isTypeFlagSet(type, ts.TypeFlags.BooleanLike)
) {
return true;
}

if (options.allowAny && util.isTypeFlagSet(type, ts.TypeFlags.Any)) {
return true;
}

if (
options.allowNullable &&
util.isTypeFlagSet(type, ts.TypeFlags.Null | ts.TypeFlags.Undefined)
) {
return true;
}

return false;
}

return {
Expand All @@ -80,70 +89,47 @@ export default util.createRule<Options, MessageId>({
return;
}

for (const expr of node.expressions) {
const type = getNodeType(expr);
if (!isAllowedType(type)) {
for (const expression of node.expressions) {
if (
!isUnderlyingExpressionTypeConfirmingTo(
expression,
isUnderlyingTypePrimitive,
)
) {
context.report({
node: expr,
node: expression,
messageId: 'invalidType',
});
}
}
},
};

/**
* Helper function to get base type of node
* @param node the node to be evaluated.
*/
function getNodeType(node: TSESTree.Expression): BaseType[] {
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
const type = util.getConstrainedTypeAtLocation(typeChecker, tsNode);
function isUnderlyingExpressionTypeConfirmingTo(
expression: TSESTree.Expression,
predicate: (underlyingType: ts.Type) => boolean,
): boolean {
return rec(getExpressionNodeType(expression));

return getBaseType(type);
}

function getBaseType(type: ts.Type): BaseType[] {
if (type.isStringLiteral()) {
return ['string'];
}
if (type.isNumberLiteral()) {
return ['number'];
}
if (type.flags & ts.TypeFlags.BigIntLiteral) {
return ['bigint'];
}
if (type.flags & ts.TypeFlags.BooleanLiteral) {
return ['boolean'];
}
if (type.flags & ts.TypeFlags.Null) {
return ['null'];
}
if (type.flags & ts.TypeFlags.Undefined) {
return ['undefined'];
}
if (type.flags & ts.TypeFlags.Any) {
return ['any'];
}
function rec(type: ts.Type): boolean {
if (type.isUnion()) {
return type.types.every(rec);
}

if (type.isUnion()) {
return type.types
.map(getBaseType)
.reduce((all, array) => [...all, ...array], []);
}
if (type.isIntersection()) {
return type.types.some(rec);
}

const stringType = typeChecker.typeToString(type);
if (
stringType === 'string' ||
stringType === 'number' ||
stringType === 'bigint' ||
stringType === 'boolean' ||
stringType === 'any'
) {
return [stringType];
return predicate(type);
}
}

return ['other'];
/**
* Helper function to extract the TS type of an TSESTree expression.
*/
function getExpressionNodeType(node: TSESTree.Expression): ts.Type {
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
return util.getConstrainedTypeAtLocation(typeChecker, tsNode);
}
},
});
Expand Up @@ -30,6 +30,12 @@ ruleTester.run('restrict-template-expressions', rule, {
return \`arg = \${arg}\`;
}
`,
// Base case - intersection type
`
function test<T extends string & { _kind: 'MyBrandedString' }>(arg: T) {
return \`arg = \${arg}\`;
}
`,
// Base case - don't check tagged templates
`
tag\`arg = \${null}\`;
Expand Down Expand Up @@ -68,6 +74,14 @@ ruleTester.run('restrict-template-expressions', rule, {
}
`,
},
{
options: [{ allowNumber: true }],
code: `
function test<T extends number & { _kind: 'MyBrandedNumber' }>(arg: T) {
return \`arg = \${arg}\`;
}
`,
},
{
options: [{ allowNumber: true }],
code: `
Expand Down Expand Up @@ -236,6 +250,13 @@ ruleTester.run('restrict-template-expressions', rule, {
`,
errors: [{ messageId: 'invalidType', line: 3, column: 30 }],
},
{
code: `
declare const arg: { a: string } & { b: string };
const msg = \`arg = \${arg}\`;
`,
errors: [{ messageId: 'invalidType', line: 3, column: 30 }],
},
{
options: [{ allowNumber: true, allowBoolean: true, allowNullable: true }],
code: `
Expand Down