Skip to content

Commit 9b82e67

Browse files
authored
feat(eslint-plugin): add new rule enforce type call (#4809)
Closes #4797
1 parent 2639f67 commit 9b82e67

File tree

7 files changed

+178
-1
lines changed

7 files changed

+178
-1
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { ESLintUtils } from '@typescript-eslint/utils';
2+
import { ruleTester } from '../../utils';
3+
import type {
4+
InvalidTestCase,
5+
ValidTestCase,
6+
} from '@typescript-eslint/rule-tester';
7+
import * as path from 'path';
8+
import rule, {
9+
enforceTypeCall,
10+
} from '../../../src/rules/signals/enforce-type-call';
11+
12+
type MessageIds = ESLintUtils.InferMessageIdsTypeFromRule<typeof rule>;
13+
type Options = readonly ESLintUtils.InferOptionsTypeFromRule<typeof rule>[];
14+
15+
const valid: () => (string | ValidTestCase<Options>)[] = () => [
16+
{
17+
code: `
18+
import { type } from '@ngrx/signals';
19+
const stateType = type<{ count: number }>();
20+
`,
21+
},
22+
{
23+
code: `
24+
import { type as typeFn } from '@ngrx/signals';
25+
const stateType = typeFn<{ count: number; name: string }>();
26+
`,
27+
},
28+
{
29+
code: `
30+
import { type } from '@none/of/business';
31+
const stateType = type<{ count: number }>;
32+
`,
33+
},
34+
];
35+
36+
const invalid: () => InvalidTestCase<MessageIds, Options>[] = () => [
37+
{
38+
code: `
39+
import { type } from '@ngrx/signals';
40+
const stateType = type<{ count: number }>;
41+
`,
42+
output: `
43+
import { type } from '@ngrx/signals';
44+
const stateType = type<{ count: number }>();
45+
`,
46+
errors: [
47+
{
48+
messageId: enforceTypeCall,
49+
data: { name: 'type' },
50+
},
51+
],
52+
},
53+
{
54+
code: `
55+
import { type as typeFn } from '@ngrx/signals';
56+
const stateType = typeFn<{ count: number }>;
57+
`,
58+
output: `
59+
import { type as typeFn } from '@ngrx/signals';
60+
const stateType = typeFn<{ count: number }>();
61+
`,
62+
errors: [
63+
{
64+
messageId: enforceTypeCall,
65+
data: { name: 'typeFn' },
66+
},
67+
],
68+
},
69+
];
70+
71+
ruleTester().run(path.parse(__filename).name, rule, {
72+
valid: valid(),
73+
invalid: invalid(),
74+
});

modules/eslint-plugin/src/configs/all.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@ngrx/signal-state-no-arrays-at-root-level": "error",
1919
"@ngrx/signal-store-feature-should-use-generic-type": "error",
2020
"@ngrx/with-state-no-arrays-at-root-level": "error",
21+
"@ngrx/enforce-type-call": "error",
2122
"@ngrx/avoid-combining-selectors": "error",
2223
"@ngrx/avoid-dispatching-multiple-actions-sequentially": "error",
2324
"@ngrx/avoid-duplicate-actions-in-reducer": "error",

modules/eslint-plugin/src/configs/all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default (
6464
'@ngrx/prefix-selectors-with-select': 'error',
6565
'@ngrx/select-style': 'error',
6666
'@ngrx/use-consistent-global-store-name': 'error',
67+
'@ngrx/enforce-type-call': 'error',
6768
},
6869
},
6970
];

modules/eslint-plugin/src/configs/signals.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"@ngrx/prefer-protected-state": "error",
66
"@ngrx/signal-state-no-arrays-at-root-level": "error",
77
"@ngrx/signal-store-feature-should-use-generic-type": "error",
8-
"@ngrx/with-state-no-arrays-at-root-level": "error"
8+
"@ngrx/with-state-no-arrays-at-root-level": "error",
9+
"@ngrx/enforce-type-call": "error"
910
},
1011
"parserOptions": {
1112
"ecmaVersion": 2020,

modules/eslint-plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import signalStateNoArraysAtRootLevel from './signals/signal-state-no-arrays-at-
3737
import signalStoreFeatureShouldUseGenericType from './signals/signal-store-feature-should-use-generic-type';
3838
import withStateNoArraysAtRootLevel from './signals/with-state-no-arrays-at-root-level';
3939
import preferProtectedState from './signals/prefer-protected-state';
40+
import enforceTypeCall from './signals/enforce-type-call';
4041

4142
export const rules = {
4243
// component-store
@@ -84,4 +85,5 @@ export const rules = {
8485
signalStoreFeatureShouldUseGenericType,
8586
'prefer-protected-state': preferProtectedState,
8687
'with-state-no-arrays-at-root-level': withStateNoArraysAtRootLevel,
88+
'enforce-type-call': enforceTypeCall,
8789
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { type TSESTree } from '@typescript-eslint/utils';
2+
import * as path from 'path';
3+
import { createRule } from '../../rule-creator';
4+
import { isCallExpression, isIdentifier } from '../../utils';
5+
6+
export const enforceTypeCall = 'enforceTypeCall';
7+
8+
type MessageIds = typeof enforceTypeCall;
9+
type Options = readonly [];
10+
11+
export default createRule<Options, MessageIds>({
12+
name: path.parse(__filename).name,
13+
meta: {
14+
type: 'problem',
15+
ngrxModule: 'signals',
16+
docs: {
17+
description: 'The `type` function must be called.',
18+
},
19+
fixable: 'code',
20+
schema: [],
21+
messages: {
22+
[enforceTypeCall]: 'The `{{name}}` function must be called.',
23+
},
24+
},
25+
defaultOptions: [],
26+
create: (context) => {
27+
// It's possible that we have multiple type import aliases, so we need to track them all.
28+
const typeNames = new Set<string>();
29+
30+
return {
31+
[`ImportDeclaration[source.value='@ngrx/signals'] ImportSpecifier[imported.name='type']`](
32+
node: TSESTree.ImportSpecifier
33+
) {
34+
typeNames.add(node.local.name);
35+
},
36+
37+
TSInstantiationExpression(node: TSESTree.TSInstantiationExpression) {
38+
const expression = node.expression;
39+
if (
40+
isIdentifier(expression) &&
41+
typeNames.has(expression.name) &&
42+
!isCallExpression(node.parent)
43+
) {
44+
context.report({
45+
node: expression,
46+
messageId: enforceTypeCall,
47+
data: { name: expression.name },
48+
fix: (fixer) => fixer.insertTextAfter(node, '()'),
49+
});
50+
}
51+
},
52+
};
53+
},
54+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# enforce-type-call
2+
3+
The `type` function from `@ngrx/signals` must be called.
4+
5+
- **Type**: problem
6+
- **Fixable**: Yes
7+
- **Suggestion**: Yes
8+
- **Requires type checking**: No
9+
- **Configurable**: No
10+
11+
<!-- Everything above this generated, do not edit -->
12+
<!-- MANUAL-DOC:START -->
13+
14+
## Rule Details
15+
16+
This rule ensures that the `type` function from `@ngrx/signals` is properly called when used to define types. The function must be invoked with parentheses `()` after the generic type parameter.
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```ts
21+
import { type } from '@ngrx/signals';
22+
const stateType = type<{ count: number }>;
23+
```
24+
25+
```ts
26+
import { type as typeFn } from '@ngrx/signals';
27+
const stateType = typeFn<{ count: number }>;
28+
```
29+
30+
Examples of **correct** code for this rule:
31+
32+
```ts
33+
import { type } from '@ngrx/signals';
34+
const stateType = type<{ count: number }>();
35+
```
36+
37+
```ts
38+
import { type as typeFn } from '@ngrx/signals';
39+
const stateType = typeFn<{ count: number; name: string }>();
40+
```
41+
42+
## Further reading
43+
44+
- [Signal Store Documentation](guide/signals/signal-store)

0 commit comments

Comments
 (0)