Skip to content

Commit

Permalink
feat: add no-unsafe-unary-minus rule (#7390)
Browse files Browse the repository at this point in the history
* feat: add `no-unsafe-unary-minus` rule

* Cover the early return case

* Write more tests

* Rewrite to use only public TypeScript API

* Handle `any`, `never`, and generics

* Replace functions with `declare` in docs
  • Loading branch information
samestep committed Nov 13, 2023
1 parent 66cd0c0 commit c4709c2
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 0 deletions.
50 changes: 50 additions & 0 deletions packages/eslint-plugin/docs/rules/no-unsafe-unary-minus.md
@@ -0,0 +1,50 @@
---
description: 'Require unary negation to take a number.'
---

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/no-unsafe-unary-minus** for documentation.
TypeScript does not prevent you from putting a minus sign before things other than numbers:

```ts
const s = 'hello';
const x = -s; // x is NaN
```

This rule restricts the unary `-` operator to `number | bigint`.

## Examples

### ❌ Incorrect

```ts
declare const a: string;
-a;

declare const b: {};
-b;
```

### ✅ Correct

```ts
-42;
-42n;

declare const a: number;
-a;

declare const b: number;
-b;

declare const c: number | bigint;
-c;

declare const d: any;
-d;

declare const e: 1 | 2;
-e;
```
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Expand Up @@ -125,6 +125,7 @@ export = {
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-unary-minus': 'error',
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': 'error',
'no-unused-vars': 'off',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -87,6 +87,7 @@ import noUnsafeDeclarationMerging from './no-unsafe-declaration-merging';
import noUnsafeEnumComparison from './no-unsafe-enum-comparison';
import noUnsafeMemberAccess from './no-unsafe-member-access';
import noUnsafeReturn from './no-unsafe-return';
import noUnsafeUnaryMinus from './no-unsafe-unary-minus';
import noUnusedExpressions from './no-unused-expressions';
import noUnusedVars from './no-unused-vars';
import noUseBeforeDefine from './no-use-before-define';
Expand Down Expand Up @@ -224,6 +225,7 @@ export default {
'no-unsafe-enum-comparison': noUnsafeEnumComparison,
'no-unsafe-member-access': noUnsafeMemberAccess,
'no-unsafe-return': noUnsafeReturn,
'no-unsafe-unary-minus': noUnsafeUnaryMinus,
'no-unused-expressions': noUnusedExpressions,
'no-unused-vars': noUnusedVars,
'no-use-before-define': noUseBeforeDefine,
Expand Down
58 changes: 58 additions & 0 deletions packages/eslint-plugin/src/rules/no-unsafe-unary-minus.ts
@@ -0,0 +1,58 @@
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';

import * as util from '../util';

type Options = [];
type MessageIds = 'unaryMinus';

export default util.createRule<Options, MessageIds>({
name: 'no-unsafe-unary-minus',
meta: {
type: 'problem',
docs: {
description: 'Require unary negation to take a number',
requiresTypeChecking: true,
},
messages: {
unaryMinus: 'Invalid type "{{type}}" of template literal expression.',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
UnaryExpression(node): void {
if (node.operator !== '-') {
return;
}
const services = util.getParserServices(context);
const argType = util.getConstrainedTypeAtLocation(
services,
node.argument,
);
const checker = services.program.getTypeChecker();
if (
tsutils
.unionTypeParts(argType)
.some(
type =>
!tsutils.isTypeFlagSet(
type,
ts.TypeFlags.Any |
ts.TypeFlags.Never |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.NumberLike,
),
)
) {
context.report({
messageId: 'unaryMinus',
node,
data: { type: checker.typeToString(argType) },
});
}
},
};
},
});
47 changes: 47 additions & 0 deletions packages/eslint-plugin/tests/rules/no-unsafe-unary-minus.test.ts
@@ -0,0 +1,47 @@
import { RuleTester } from '@typescript-eslint/rule-tester';

import rule from '../../src/rules/no-unsafe-unary-minus';
import { getFixturesRootDir } from '../RuleTester';

const rootDir = getFixturesRootDir();
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2015,
tsconfigRootDir: rootDir,
project: './tsconfig.json',
},
parser: '@typescript-eslint/parser',
});

ruleTester.run('no-unsafe-unary-minus', rule, {
valid: [
'+42;',
'-42;',
'-42n;',
'(a: number) => -a;',
'(a: bigint) => -a;',
'(a: number | bigint) => -a;',
'(a: any) => -a;',
'(a: 1 | 2) => -a;',
'(a: string) => +a;',
'(a: number[]) => -a[0];',
'<T,>(t: T & number) => -t;',
'(a: { x: number }) => -a.x;',
'(a: never) => -a;',
'<T extends number>(t: T) => -t;',
],
invalid: [
{ code: '(a: string) => -a;', errors: [{ messageId: 'unaryMinus' }] },
{ code: '(a: {}) => -a;', errors: [{ messageId: 'unaryMinus' }] },
{ code: '(a: number[]) => -a;', errors: [{ messageId: 'unaryMinus' }] },
{ code: "-'hello';", errors: [{ messageId: 'unaryMinus' }] },
{ code: '-`hello`;', errors: [{ messageId: 'unaryMinus' }] },
{
code: '(a: { x: number }) => -a;',
errors: [{ messageId: 'unaryMinus' }],
},
{ code: '(a: unknown) => -a;', errors: [{ messageId: 'unaryMinus' }] },
{ code: '(a: void) => -a;', errors: [{ messageId: 'unaryMinus' }] },
{ code: '<T,>(t: T) => -t;', errors: [{ messageId: 'unaryMinus' }] },
],
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c4709c2

Please sign in to comment.