Skip to content

Commit 31b82ce

Browse files
committed
[code-infra] Create custom rule for undefined in optional props
This rule naively (currently) checks for all optional properties in interface and types and provides autofix to add `| undefined` wherever it doesn't exist. One slight optimization is added where locally defined types are also checked for `| undefined` and if it already has `undefined`, its not added at places where the type is used.
1 parent 3e3342f commit 31b82ce

File tree

7 files changed

+691
-97
lines changed

7 files changed

+691
-97
lines changed

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@
4646
"@tsconfig/node22": "^22.0.2",
4747
"@types/node": "^22.18.13",
4848
"@types/semver": "^7.7.1",
49-
"@typescript-eslint/eslint-plugin": "8.46.3",
50-
"@typescript-eslint/parser": "^8.46.3",
5149
"@vitest/coverage-v8": "^4.0.7",
5250
"eslint": "^9.39.1",
5351
"jsdom": "^27.1.0",

packages/code-infra/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
"@octokit/oauth-methods": "^6.0.2",
7878
"@octokit/rest": "^22.0.1",
7979
"@pnpm/find-workspace-dir": "^1000.1.3",
80+
"@typescript-eslint/types": "^8.47.0",
81+
"@typescript-eslint/utils": "^8.47.0",
8082
"babel-plugin-optimize-clsx": "^2.6.2",
8183
"babel-plugin-react-compiler": "^1.0.0",
8284
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
@@ -109,7 +111,7 @@
109111
"resolve-pkg-maps": "^1.0.0",
110112
"semver": "^7.7.3",
111113
"stylelint-config-standard": "^39.0.1",
112-
"typescript-eslint": "^8.46.3",
114+
"typescript-eslint": "^8.47.0",
113115
"yargs": "^18.0.0"
114116
},
115117
"peerDependencies": {

packages/code-infra/src/eslint/material-ui/config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ export function createCoreConfig(options = {}) {
412412
'material-ui/no-empty-box': 'error',
413413
'material-ui/no-styled-box': 'error',
414414
'material-ui/straight-quotes': 'off',
415+
'material-ui/add-undef-to-optional': 'off',
415416

416417
'react-hooks/exhaustive-deps': [
417418
'error',

packages/code-infra/src/eslint/material-ui/index.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import noRestrictedResolvedImports from './rules/no-restricted-resolved-imports.
77
import noStyledBox from './rules/no-styled-box.mjs';
88
import rulesOfUseThemeVariants from './rules/rules-of-use-theme-variants.mjs';
99
import straightQuotes from './rules/straight-quotes.mjs';
10+
import addUndefToOptional from './rules/add-undef-to-optional.mjs';
1011

1112
export default /** @type {import('eslint').ESLint.Plugin} */ ({
1213
meta: {
@@ -23,5 +24,7 @@ export default /** @type {import('eslint').ESLint.Plugin} */ ({
2324
'straight-quotes': straightQuotes,
2425
'disallow-react-api-in-server-components': disallowReactApiInServerComponents,
2526
'no-restricted-resolved-imports': noRestrictedResolvedImports,
27+
// Some descrepancies between TypeScript and ESLint types - casting to unknown
28+
'add-undef-to-optional': /** @type {unknown} */ (addUndefToOptional),
2629
},
2730
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils';
2+
3+
const createRule = ESLintUtils.RuleCreator(
4+
(name) =>
5+
`https://github.com/mui/mui-public/blob/master/packages/code-infra/src/eslint/material-ui/rules/${name}.mjs`,
6+
);
7+
8+
const RULE_NAME = 'add-undef-to-optional';
9+
10+
/**
11+
* Checks whether the given type node includes 'undefined' either directly,
12+
* via union, or via type references that eventually include 'undefined'.
13+
* Treats 'any' and 'unknown' as including 'undefined' and skips React's ReactNode.
14+
*
15+
* @param {import('@typescript-eslint/types').TSESTree.TSTypeAnnotation['typeAnnotation'] | undefined} typeNode
16+
* @param {Map<string, any>} typeDefinitions
17+
* @returns {boolean}
18+
*/
19+
function souldCheckProperty(typeNode, typeDefinitions) {
20+
if (!typeNode) {
21+
return false;
22+
}
23+
24+
switch (typeNode.type) {
25+
case AST_NODE_TYPES.TSUnionType: {
26+
return typeNode.types.some((t) => souldCheckProperty(t, typeDefinitions));
27+
}
28+
case AST_NODE_TYPES.TSUndefinedKeyword:
29+
return true;
30+
case AST_NODE_TYPES.TSAnyKeyword:
31+
return true;
32+
case AST_NODE_TYPES.TSUnknownKeyword:
33+
return true;
34+
case AST_NODE_TYPES.TSTypeReference: {
35+
// Check if it's a reference to 'undefined' itself
36+
if (
37+
typeNode.typeName &&
38+
typeNode.typeName.type === AST_NODE_TYPES.Identifier &&
39+
typeNode.typeName.name === 'undefined'
40+
) {
41+
return true;
42+
}
43+
// Check if it's ReactNode (which already includes undefined)
44+
if (typeNode.typeName) {
45+
if (typeNode.typeName.type === AST_NODE_TYPES.Identifier) {
46+
const typeName = typeNode.typeName.name;
47+
// ReactNode already includes undefined
48+
if (typeName === 'ReactNode') {
49+
return true;
50+
}
51+
// If we have a local definition, check it
52+
if (typeDefinitions.has(typeName)) {
53+
const typeDefinition = typeDefinitions.get(typeName);
54+
return souldCheckProperty(typeDefinition, typeDefinitions);
55+
}
56+
// If no local definition found, it's imported or built-in - require explicit | undefined
57+
return false;
58+
}
59+
// Check for React.ReactNode
60+
if (
61+
typeNode.typeName.type === AST_NODE_TYPES.TSQualifiedName &&
62+
typeNode.typeName.left.type === AST_NODE_TYPES.Identifier &&
63+
typeNode.typeName.left.name === 'React' &&
64+
typeNode.typeName.right.name === 'ReactNode'
65+
) {
66+
return true;
67+
}
68+
}
69+
return false;
70+
}
71+
default:
72+
return false;
73+
}
74+
}
75+
76+
export default createRule({
77+
meta: {
78+
docs: {
79+
description: 'Ensures that optional properties include undefined in their type.',
80+
},
81+
messages: {
82+
addUndefined:
83+
'Optional property "{{ propName }}" type does not explicitly include undefined. Add "| undefined".',
84+
},
85+
type: 'suggestion',
86+
fixable: 'code',
87+
schema: [],
88+
},
89+
name: RULE_NAME,
90+
defaultOptions: [],
91+
create(context) {
92+
const typeDefinitions = new Map();
93+
94+
return {
95+
// Collect type alias definitions, ie, type Foo = ...
96+
TSTypeAliasDeclaration(node) {
97+
if (node.id && node.typeAnnotation) {
98+
typeDefinitions.set(node.id.name, node.typeAnnotation);
99+
}
100+
},
101+
// only checks optional properties in types/interfaces
102+
TSPropertySignature(node) {
103+
if (!node.optional || !node.typeAnnotation) {
104+
return;
105+
}
106+
const typeNode = node.typeAnnotation.typeAnnotation;
107+
if (!typeNode || souldCheckProperty(typeNode, typeDefinitions)) {
108+
return;
109+
}
110+
const source = context.sourceCode;
111+
context.report({
112+
node: node.key ?? node,
113+
messageId: 'addUndefined',
114+
data: {
115+
propName: source.getText(node.key),
116+
},
117+
fix(fixer) {
118+
const text = source.getText(typeNode);
119+
return fixer.replaceText(typeNode, `${text} | undefined`);
120+
},
121+
});
122+
},
123+
};
124+
},
125+
});

0 commit comments

Comments
 (0)