Skip to content

Commit

Permalink
feat(eslint-plugin): add rule restrict-template-expressions (#850)
Browse files Browse the repository at this point in the history
Co-authored-by: Brad Zacher <brad.zacher@gmail.com>
  • Loading branch information
phaux and bradzacher committed Nov 18, 2019
1 parent 42a48de commit 46b58b4
Show file tree
Hide file tree
Showing 6 changed files with 436 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -200,6 +200,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e
| [`@typescript-eslint/require-array-sort-compare`](./docs/rules/require-array-sort-compare.md) | Enforce giving `compare` argument to `Array#sort` | | | :thought_balloon: |
| [`@typescript-eslint/require-await`](./docs/rules/require-await.md) | Disallow async functions which have no `await` expression | :heavy_check_mark: | | :thought_balloon: |
| [`@typescript-eslint/restrict-plus-operands`](./docs/rules/restrict-plus-operands.md) | When adding two variables, operands must both be of type number or of type string | | | :thought_balloon: |
| [`@typescript-eslint/restrict-template-expressions`](./docs/rules/restrict-template-expressions.md) | Enforce template literal expressions to be of string type | | | :thought_balloon: |
| [`@typescript-eslint/semi`](./docs/rules/semi.md) | Require or disallow semicolons instead of ASI | | :wrench: | |
| [`@typescript-eslint/space-before-function-paren`](./docs/rules/space-before-function-paren.md) | enforce consistent spacing before `function` definition opening parenthesis | | :wrench: | |
| [`@typescript-eslint/strict-boolean-expressions`](./docs/rules/strict-boolean-expressions.md) | Restricts the types allowed in boolean expressions | | | :thought_balloon: |
Expand Down
69 changes: 69 additions & 0 deletions packages/eslint-plugin/docs/rules/restrict-template-expressions.md
@@ -0,0 +1,69 @@
# Enforce template literal expressions to be of string type. (restrict-template-expressions)

Examples of **correct** code:

```ts
const arg = 'foo';
const msg1 = `arg = ${arg}`;
const msg2 = `arg = ${arg || 'default'}`;
```

Examples of **incorrect** code:

```ts
const arg1 = [1, 2];
const msg1 = `arg1 = ${arg1}`;

const arg2 = { name: 'Foo' };
const msg2 = `arg2 = ${arg2 || null}`;
```

## Options

The rule accepts an options object with the following properties:

```ts
type Options = {
// if true, also allow number type in template expressions
allowNumber?: boolean;
// if true, also allow boolean type in template expressions
allowBoolean?: boolean;
// if true, also allow null and undefined in template expressions
allowNullable?: boolean;
};

const defaults = {
allowNumber: false,
allowBoolean: false,
allowNullable: false,
};
```

### allowNumber

Examples of additional **correct** code for this rule with `{ allowNumber: true }`:

```ts
const arg = 123;
const msg1 = `arg = ${arg}`;
const msg2 = `arg = ${arg || 'zero'}`;
```

### allowBoolean

Examples of additional **correct** code for this rule with `{ allowBoolean: true }`:

```ts
const arg = true;
const msg1 = `arg = ${arg}`;
const msg2 = `arg = ${arg || 'not truthy'}`;
```

### allowNullable

Examples of additional **correct** code for this rule with `{ allowNullable: true }`:

```ts
const arg = condition ? 'ok' : null;
const msg1 = `arg = ${arg}`;
```
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.json
Expand Up @@ -75,6 +75,7 @@
"require-await": "off",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/restrict-plus-operands": "error",
"@typescript-eslint/restrict-template-expressions": "error",
"semi": "off",
"@typescript-eslint/semi": "error",
"space-before-function-paren": "off",
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -57,6 +57,7 @@ import quotes from './quotes';
import requireArraySortCompare from './require-array-sort-compare';
import requireAwait from './require-await';
import restrictPlusOperands from './restrict-plus-operands';
import restrictTemplateExpressions from './restrict-template-expressions';
import semi from './semi';
import spaceBeforeFunctionParen from './space-before-function-paren';
import strictBooleanExpressions from './strict-boolean-expressions';
Expand Down Expand Up @@ -128,6 +129,7 @@ export default {
'require-array-sort-compare': requireArraySortCompare,
'require-await': requireAwait,
'restrict-plus-operands': restrictPlusOperands,
'restrict-template-expressions': restrictTemplateExpressions,
semi: semi,
'space-before-function-paren': spaceBeforeFunctionParen,
'strict-boolean-expressions': strictBooleanExpressions,
Expand Down
150 changes: 150 additions & 0 deletions packages/eslint-plugin/src/rules/restrict-template-expressions.ts
@@ -0,0 +1,150 @@
import {
TSESTree,
AST_NODE_TYPES,
} from '@typescript-eslint/experimental-utils';
import ts from 'typescript';
import * as util from '../util';

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

type MessageId = 'invalidType';

export default util.createRule<Options, MessageId>({
name: 'restrict-template-expressions',
meta: {
type: 'problem',
docs: {
description: 'Enforce template literal expressions to be of string type',
category: 'Best Practices',
recommended: false,
requiresTypeChecking: true,
},
messages: {
invalidType: 'Invalid type of template literal expression.',
},
schema: [
{
type: 'object',
properties: {
allowBoolean: { type: 'boolean' },
allowNullable: { type: 'boolean' },
allowNumber: { type: 'boolean' },
},
},
],
},
defaultOptions: [{}],
create(context, [options]) {
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;
}
}
return true;
}

return {
TemplateLiteral(node: TSESTree.TemplateLiteral): void {
// don't check tagged template literals
if (node.parent!.type === AST_NODE_TYPES.TaggedTemplateExpression) {
return;
}

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

/**
* Helper function to get base type of node
* @param node the node to be evaluated.
*/
function getNodeType(node: TSESTree.Node): BaseType[] {
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
const type = typeChecker.getTypeAtLocation(tsNode);

return getBaseType(type);
}

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'];
}

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

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

return ['other'];
}
},
});

0 comments on commit 46b58b4

Please sign in to comment.