Skip to content

Commit

Permalink
feat(eslint-plugin): [restrict-template-expressions] add support for …
Browse files Browse the repository at this point in the history
…intersection types (fixes #1797)
  • Loading branch information
ulrichb committed Mar 27, 2020
1 parent b1b8284 commit 054e62e
Show file tree
Hide file tree
Showing 2 changed files with 82 additions and 77 deletions.
138 changes: 61 additions & 77 deletions packages/eslint-plugin/src/rules/restrict-template-expressions.ts
Expand Up @@ -44,29 +44,36 @@ export default util.createRule<Options, MessageId>({
const service = util.getParserServices(context);
const typeChecker = service.program.getTypeChecker();

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

const allowedTypes: BaseType[] = [
'string',
...(options.allowNumber ? (['number', 'bigint'] as const) : []),
...(options.allowBoolean ? (['boolean'] as const) : []),
...(options.allowNullable ? (['null', 'undefined'] 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 (
util.isTypeFlagSet(
type,
ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike,
) &&
options.allowNumber
) {
return true;
}
return true;

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

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

return false;
}

return {
Expand All @@ -76,75 +83,52 @@ 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 = typeChecker.getTypeAtLocation(tsNode);
function isUnderlyingExpressionTypeConfirmingTo(
expression: TSESTree.Expression,
predicate: (underlyingType: ts.Type) => boolean,
): boolean {
const expressionType = getExpressionNodeType(expression);

return getBaseType(type);
}
return rec(
// "Extracts" generic constraint, indexed access and conditional types:
typeChecker.getBaseConstraintOfType(expressionType) ?? expressionType,
);

function getBaseType(type: ts.Type): BaseType[] {
const constraint = type.getConstraint();
if (
constraint &&
// for generic types with union constraints, it will return itself
constraint !== type
) {
return getBaseType(constraint);
}

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'];
}
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'
) {
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 typeChecker.getTypeAtLocation(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 @@ -199,6 +213,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

0 comments on commit 054e62e

Please sign in to comment.