diff --git a/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.mdx b/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.mdx index 8aeb34e238d5..5c980af355b7 100644 --- a/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.mdx +++ b/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.mdx @@ -9,18 +9,24 @@ import TabItem from '@theme/TabItem'; > > See **https://typescript-eslint.io/rules/consistent-indexed-object-style** for documentation. -TypeScript supports defining arbitrary object keys using an index signature. TypeScript also has a builtin type named `Record` to create an empty object defining only an index signature. For example, the following types are equal: +TypeScript supports defining arbitrary object keys using an index signature or mapped type. +TypeScript also has a builtin type named `Record` to create an empty object defining only an index signature. +For example, the following types are equal: ```ts -interface Foo { +interface IndexSignatureInterface { [key: string]: unknown; } -type Foo = { +type IndexSignatureType = { [key: string]: unknown; }; -type Foo = Record; +type MappedType = { + [key in string]: unknown; +}; + +type RecordType = Record; ``` Using one declaration form consistently improves code readability. @@ -38,20 +44,24 @@ Using one declaration form consistently improves code readability. ```ts option='"record"' -interface Foo { +interface IndexSignatureInterface { [key: string]: unknown; } -type Foo = { +type IndexSignatureType = { [key: string]: unknown; }; + +type MappedType = { + [key in string]: unknown; +}; ``` ```ts option='"record"' -type Foo = Record; +type RecordType = Record; ``` @@ -63,20 +73,24 @@ type Foo = Record; ```ts option='"index-signature"' -type Foo = Record; +type RecordType = Record; ``` ```ts option='"index-signature"' -interface Foo { +interface IndexSignatureInterface { [key: string]: unknown; } -type Foo = { +type IndexSignatureType = { [key: string]: unknown; }; + +type MappedType = { + [key in string]: unknown; +}; ``` diff --git a/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts b/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts index ad17704bf1fb..768bdc84491a 100644 --- a/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts +++ b/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts @@ -1,8 +1,9 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint'; import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils'; -import { createRule } from '../util'; +import { createRule, isParenthesized, nullThrows } from '../util'; type MessageIds = 'preferIndexSignature' | 'preferRecord'; type Options = ['index-signature' | 'record']; @@ -142,6 +143,69 @@ export default createRule({ !node.extends.length, ); }, + TSMappedType(node): void { + const key = node.key; + const scope = context.sourceCode.getScope(key); + + const scopeManagerKey = nullThrows( + scope.variables.find( + value => value.name === key.name && value.isTypeVariable, + ), + 'key type parameter must be a defined type variable in its scope', + ); + + // If the key is used to compute the value, we can't convert to a Record. + if ( + scopeManagerKey.references.some( + reference => reference.isTypeReference, + ) + ) { + return; + } + + const constraint = node.constraint; + + if ( + constraint.type === AST_NODE_TYPES.TSTypeOperator && + constraint.operator === 'keyof' && + !isParenthesized(constraint, context.sourceCode) + ) { + // This is a weird special case, since modifiers are preserved by + // the mapped type, but not by the Record type. So this type is not, + // in general, equivalent to a Record type. + return; + } + + // There's no builtin Mutable type, so we can't autofix it really. + const canFix = node.readonly !== '-'; + + context.report({ + node, + messageId: 'preferRecord', + ...(canFix && { + fix: (fixer): ReturnType => { + const keyType = context.sourceCode.getText(constraint); + const valueType = context.sourceCode.getText( + node.typeAnnotation, + ); + + let recordText = `Record<${keyType}, ${valueType}>`; + + if (node.optional === '+' || node.optional === true) { + recordText = `Partial<${recordText}>`; + } else if (node.optional === '-') { + recordText = `Required<${recordText}>`; + } + + if (node.readonly === '+' || node.readonly === true) { + recordText = `Readonly<${recordText}>`; + } + + return fixer.replaceText(node, recordText); + }, + }), + }); + }, TSTypeLiteral(node): void { const parent = findParentDeclaration(node); checkMembers(node.members, node, parent?.id, '', ''); diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index 98933d502726..3049c1fe3fcf 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -57,9 +57,9 @@ const optionTesters = ( tester, })); type Options = [ - { [Type in (typeof optionTesters)[number]['option']]?: boolean } & { + { allow?: TypeOrValueSpecifier[]; - }, + } & Partial>, ]; type MessageId = 'invalidType'; diff --git a/packages/eslint-plugin/src/rules/typedef.ts b/packages/eslint-plugin/src/rules/typedef.ts index 7a18ac37b572..dd70a97706ec 100644 --- a/packages/eslint-plugin/src/rules/typedef.ts +++ b/packages/eslint-plugin/src/rules/typedef.ts @@ -15,7 +15,7 @@ const enum OptionKeys { VariableDeclarationIgnoreFunction = 'variableDeclarationIgnoreFunction', } -type Options = { [k in OptionKeys]?: boolean }; +type Options = Partial>; type MessageIds = 'expectedTypedef' | 'expectedTypedefNamed'; diff --git a/packages/eslint-plugin/src/util/types.ts b/packages/eslint-plugin/src/util/types.ts index 0765b2683d6e..44166a7649dc 100644 --- a/packages/eslint-plugin/src/util/types.ts +++ b/packages/eslint-plugin/src/util/types.ts @@ -1,5 +1,4 @@ -export type MakeRequired = { - [K in Key]-?: NonNullable; -} & Omit; +export type MakeRequired = Omit & + Required>>; export type ValueOf = T[keyof T]; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/consistent-indexed-object-style.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/consistent-indexed-object-style.shot index 3acf9ee188aa..95c1324cb74c 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/consistent-indexed-object-style.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/consistent-indexed-object-style.shot @@ -4,19 +4,26 @@ exports[`Validating rule docs consistent-indexed-object-style.mdx code examples "Incorrect Options: "record" -interface Foo { -~~~~~~~~~~~~~~~ A record is preferred over an index signature. +interface IndexSignatureInterface { +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A record is preferred over an index signature. [key: string]: unknown; ~~~~~~~~~~~~~~~~~~~~~~~~~ } ~ -type Foo = { - ~ A record is preferred over an index signature. +type IndexSignatureType = { + ~ A record is preferred over an index signature. [key: string]: unknown; ~~~~~~~~~~~~~~~~~~~~~~~~~ }; ~ + +type MappedType = { + ~ A record is preferred over an index signature. + [key in string]: unknown; +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +}; +~ " `; @@ -24,7 +31,7 @@ exports[`Validating rule docs consistent-indexed-object-style.mdx code examples "Correct Options: "record" -type Foo = Record; +type RecordType = Record; " `; @@ -32,8 +39,8 @@ exports[`Validating rule docs consistent-indexed-object-style.mdx code examples "Incorrect Options: "index-signature" -type Foo = Record; - ~~~~~~~~~~~~~~~~~~~~~~~ An index signature is preferred over a record. +type RecordType = Record; + ~~~~~~~~~~~~~~~~~~~~~~~ An index signature is preferred over a record. " `; @@ -41,12 +48,16 @@ exports[`Validating rule docs consistent-indexed-object-style.mdx code examples "Correct Options: "index-signature" -interface Foo { +interface IndexSignatureInterface { [key: string]: unknown; } -type Foo = { +type IndexSignatureType = { [key: string]: unknown; }; + +type MappedType = { + [key in string]: unknown; +}; " `; diff --git a/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts b/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts index b6d4e8dff432..40da904bbac9 100644 --- a/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts +++ b/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts @@ -1,4 +1,4 @@ -import { RuleTester } from '@typescript-eslint/rule-tester'; +import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; import rule from '../../src/rules/consistent-indexed-object-style'; @@ -141,6 +141,28 @@ interface Foo { code: 'type T = A.B;', options: ['index-signature'], }, + + { + // mapped type that uses the key cannot be converted to record + code: 'type T = { [key in Foo]: key | number };', + }, + { + code: ` +function foo(e: { readonly [key in PropertyKey]-?: key }) {} + `, + }, + + { + // `in keyof` mapped types are not convertible to Record. + code: ` +function f(): { + // intentionally not using a Record to preserve optionals + [k in keyof ParseResult]: unknown; +} { + return {}; +} + `, + }, ], invalid: [ // Interface @@ -391,5 +413,161 @@ interface Foo { options: ['index-signature'], output: 'function foo(): { [key: string]: any } {}', }, + { + code: 'type T = { readonly [key in string]: number };', + errors: [{ column: 10, messageId: 'preferRecord' }], + output: `type T = Readonly>;`, + }, + { + code: 'type T = { +readonly [key in string]: number };', + errors: [{ column: 10, messageId: 'preferRecord' }], + output: `type T = Readonly>;`, + }, + { + // There is no fix, since there isn't a builtin Mutable :( + code: 'type T = { -readonly [key in string]: number };', + errors: [{ column: 10, messageId: 'preferRecord' }], + }, + { + code: 'type T = { [key in string]: number };', + errors: [{ column: 10, messageId: 'preferRecord' }], + output: `type T = Record;`, + }, + { + code: ` +function foo(e: { [key in PropertyKey]?: string }) {} + `, + errors: [ + { + column: 17, + endColumn: 50, + endLine: 2, + line: 2, + messageId: 'preferRecord', + }, + ], + output: ` +function foo(e: Partial>) {} + `, + }, + { + code: ` +function foo(e: { [key in PropertyKey]+?: string }) {} + `, + errors: [ + { + column: 17, + endColumn: 51, + endLine: 2, + line: 2, + messageId: 'preferRecord', + }, + ], + output: ` +function foo(e: Partial>) {} + `, + }, + { + code: ` +function foo(e: { [key in PropertyKey]-?: string }) {} + `, + errors: [ + { + column: 17, + endColumn: 51, + endLine: 2, + line: 2, + messageId: 'preferRecord', + }, + ], + output: ` +function foo(e: Required>) {} + `, + }, + { + code: ` +function foo(e: { readonly [key in PropertyKey]-?: string }) {} + `, + errors: [ + { + column: 17, + endColumn: 60, + endLine: 2, + line: 2, + messageId: 'preferRecord', + }, + ], + output: ` +function foo(e: Readonly>>) {} + `, + }, + { + code: ` +type Options = [ + { [Type in (typeof optionTesters)[number]['option']]?: boolean } & { + allow?: TypeOrValueSpecifier[]; + }, +]; + `, + errors: [ + { + column: 3, + endColumn: 67, + endLine: 3, + line: 3, + messageId: 'preferRecord', + }, + ], + output: ` +type Options = [ + Partial> & { + allow?: TypeOrValueSpecifier[]; + }, +]; + `, + }, + { + code: ` +export type MakeRequired = { + [K in Key]-?: NonNullable; +} & Omit; + `, + errors: [ + { + column: 58, + endColumn: 2, + endLine: 4, + line: 2, + messageId: 'preferRecord', + }, + ], + output: ` +export type MakeRequired = Required>> & Omit; + `, + }, + { + // in parenthesized expression is convertible to Record + code: noFormat` +function f(): { + [k in (keyof ParseResult)]: unknown; +} { + return {}; +} + `, + errors: [ + { + column: 15, + endColumn: 2, + endLine: 4, + line: 2, + messageId: 'preferRecord', + }, + ], + output: ` +function f(): Record { + return {}; +} + `, + }, ], }); diff --git a/packages/scope-manager/src/referencer/VisitorBase.ts b/packages/scope-manager/src/referencer/VisitorBase.ts index ad8fba8defb1..f61ac2ca2102 100644 --- a/packages/scope-manager/src/referencer/VisitorBase.ts +++ b/packages/scope-manager/src/referencer/VisitorBase.ts @@ -15,9 +15,9 @@ function isNode(node: unknown): node is TSESTree.Node { return isObject(node) && typeof node.type === 'string'; } -type NodeVisitor = { - [K in AST_NODE_TYPES]?: (node: TSESTree.Node) => void; -}; +type NodeVisitor = Partial< + Record void> +>; abstract class VisitorBase { readonly #childVisitorKeys: VisitorKeys; diff --git a/packages/website/src/theme/MDXComponents/RuleAttributes.tsx b/packages/website/src/theme/MDXComponents/RuleAttributes.tsx index 338945fd2e6d..2338ef8fd0f9 100644 --- a/packages/website/src/theme/MDXComponents/RuleAttributes.tsx +++ b/packages/website/src/theme/MDXComponents/RuleAttributes.tsx @@ -20,9 +20,8 @@ const recommendations = { stylistic: [STYLISTIC_CONFIG_EMOJI, 'stylistic'], }; -type MakeRequired = Omit & { - [K in Key]-?: NonNullable; -}; +type MakeRequired = Omit & + Required>>; type RecommendedRuleMetaDataDocs = MakeRequired< ESLintPluginDocs,