Skip to content

⬅️ Back to Table of Contents

📄 consistent-indexed-object-style

📊 Analysis Summary

Metric Count
🔧 Functions 8
📦 Imports 11
📑 Type Aliases 2

📚 Table of Contents

🛠️ File Location:

📂 packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts

📤 Default Export

export default createRule<Options, MessageIds>({ ... })
Property Value
name 'consistent-indexed-object-style'
meta.type 'suggestion'
meta.docs.description 'Require or disallow the Record type'
meta.docs.recommended 'stylistic'
meta.fixable 'code'
meta.hasSuggestions true
meta.messages.preferIndexSignature 'An index signature is preferred over a record.'
meta.messages.preferIndexSignatureSuggestion 'Change into an index signature instead of a record.'
meta.messages.preferRecord 'A record is preferred over an index signature.'
meta.messages.preferRecordSuggestion 'Change into a record instead of an index signature.'
meta.schema [ { type: 'string', description: 'Which indexed object syntax to prefer.', enum: ['record', 'index-signature'], }, ]
defaultOptions ['record']

Entry point: create — documented under Functions.


📦 Imports

Name Source
ScopeVariable @typescript-eslint/scope-manager
TSESLint @typescript-eslint/utils
TSESTree @typescript-eslint/utils
ReportFixFunction @typescript-eslint/utils/ts-eslint
AST_NODE_TYPES @typescript-eslint/utils
ASTUtils @typescript-eslint/utils
createRule ../util
getFixOrSuggest ../util
isNodeEqual ../util
isParenthesized ../util
nullThrows ../util

Functions

create(context: any, [mode]: any): { TSInterfaceDeclaration(node: any): void; TSMappedType(nod…

Parameters:

  • context any
  • [mode] any

Returns: { TSInterfaceDeclaration(node: any): void; TSMappedType(node: any): void; TSTypeLiteral(node: any): void; TSTypeReference(node: any): void; }

Calls:

  • context.sourceCode .getCommentsInside(node) .some
  • preserved.every
  • member.parameters.at
  • context.sourceCode.getScope
  • ASTUtils.findVariable
  • isDeeplyReferencingType
  • context.report
  • getFixOrSuggest (from ../util)
  • hasUnpreservedComments
  • context.sourceCode.getText
  • fixer.replaceText
  • node.typeParameters.params .map(p => context.sourceCode.getText(p)) .join
  • checkMembers
  • nullThrows (from ../util)
  • scope.variables.find
  • scopeManagerKey.references.some
  • isParenthesized (from ../util)
  • findParentDeclaration

Internal Comments:

// The fixers rebuild the type from the text of a few sub-nodes, so a
// comment inside `node` but outside all of those preserved sub-nodes would
// be dropped by the fix. Returns true when at least one such comment exists.
// If the key is used to compute the value, we can't convert to a Record.
// 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.
// If the mapped type is circular, we can't convert it to a Record. (x2)
// There's no builtin Mutable<T> type, so a `-readonly` mapped (x2)
// type can't be represented as a Record and is left untouched. (x2)

Code
create(context, [mode]) {
    // The fixers rebuild the type from the text of a few sub-nodes, so a
    // comment inside `node` but outside all of those preserved sub-nodes would
    // be dropped by the fix. Returns true when at least one such comment exists.
    function hasUnpreservedComments(
      node: TSESTree.Node,
      ...preserved: (TSESTree.Node | undefined)[]
    ): boolean {
      return context.sourceCode
        .getCommentsInside(node)
        .some(comment =>
          preserved.every(
            target =>
              target == null ||
              comment.range[0] < target.range[0] ||
              comment.range[1] > target.range[1],
          ),
        );
    }

    function checkMembers(
      members: TSESTree.TypeElement[],
      node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeLiteral,
      parentId: TSESTree.Identifier | undefined,
      prefix: string,
      postfix: string,
      safeFix = true,
    ): void {
      if (members.length !== 1) {
        return;
      }
      const [member] = members;

      if (member.type !== AST_NODE_TYPES.TSIndexSignature) {
        return;
      }

      const parameter = member.parameters.at(0);
      if (parameter?.type !== AST_NODE_TYPES.Identifier) {
        return;
      }

      const keyType = parameter.typeAnnotation;
      if (!keyType) {
        return;
      }

      const valueType = member.typeAnnotation;
      if (!valueType) {
        return;
      }

      if (parentId) {
        const scope = context.sourceCode.getScope(parentId);
        const superVar = ASTUtils.findVariable(scope, parentId.name);

        if (
          superVar &&
          isDeeplyReferencingType(node, superVar, new Set([parentId]))
        ) {
          return;
        }
      }

      context.report({
        node,
        messageId: 'preferRecord',
        ...getFixOrSuggest({
          fixOrSuggest: !safeFix
            ? 'none'
            : hasUnpreservedComments(
                  node,
                  keyType.typeAnnotation,
                  valueType.typeAnnotation,
                )
              ? 'suggest'
              : 'fix',
          suggestion: {
            messageId: 'preferRecordSuggestion',
            fix: (fixer): TSESLint.RuleFix => {
              const key = context.sourceCode.getText(keyType.typeAnnotation);
              const value = context.sourceCode.getText(
                valueType.typeAnnotation,
              );
              const record = member.readonly
                ? `Readonly<Record<${key}, ${value}>>`
                : `Record<${key}, ${value}>`;
              return fixer.replaceText(node, `${prefix}${record}${postfix}`);
            },
          },
        }),
      });
    }

    return {
      ...(mode === 'index-signature' && {
        TSTypeReference(node): void {
          const typeName = node.typeName;
          if (typeName.type !== AST_NODE_TYPES.Identifier) {
            return;
          }
          if (typeName.name !== 'Record') {
            return;
          }

          const params = node.typeArguments?.params;
          if (params?.length !== 2) {
            return;
          }

          const indexParam = params[0];

          const shouldFix =
            indexParam.type === AST_NODE_TYPES.TSStringKeyword ||
            indexParam.type === AST_NODE_TYPES.TSNumberKeyword ||
            indexParam.type === AST_NODE_TYPES.TSSymbolKeyword;

          context.report({
            node,
            messageId: 'preferIndexSignature',
            ...getFixOrSuggest({
              fixOrSuggest:
                shouldFix && !hasUnpreservedComments(node, params[0], params[1])
                  ? 'fix'
                  : 'suggest',
              suggestion: {
                messageId: 'preferIndexSignatureSuggestion',
                fix: fixer => {
                  const key = context.sourceCode.getText(params[0]);
                  const type = context.sourceCode.getText(params[1]);
                  return fixer.replaceText(node, `{ [key: ${key}]: ${type} }`);
                },
              },
            }),
          });
        },
      }),
      ...(mode === 'record' && {
        TSInterfaceDeclaration(node): void {
          let genericTypes = '';

          if (node.typeParameters?.params.length) {
            genericTypes = `<${node.typeParameters.params
              .map(p => context.sourceCode.getText(p))
              .join(', ')}>`;
          }

          checkMembers(
            node.body.body,
            node,
            node.id,
            `type ${node.id.name}${genericTypes} = `,
            ';',
            !node.extends.length &&
              node.parent.type !== AST_NODE_TYPES.ExportDefaultDeclaration,
          );
        },
        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;
          }

          // If the mapped type is circular, we can't convert it to a Record.
          const parentId = findParentDeclaration(node)?.id;
          if (parentId) {
            const scope = context.sourceCode.getScope(key);
            const superVar = ASTUtils.findVariable(scope, parentId.name);
            if (superVar) {
              const isCircular = isDeeplyReferencingType(
                node.parent,
                superVar,
                new Set([parentId]),
              );
              if (isCircular) {
                return;
              }
            }
          }

          context.report({
            node,
            messageId: 'preferRecord',
            ...getFixOrSuggest({
              // There's no builtin Mutable<T> type, so a `-readonly` mapped
              // type can't be represented as a Record and is left untouched.
              fixOrSuggest:
                node.readonly === '-'
                  ? 'none'
                  : hasUnpreservedComments(
                        node,
                        constraint,
                        node.typeAnnotation,
                      )
                    ? 'suggest'
                    : 'fix',
              suggestion: {
                messageId: 'preferRecordSuggestion',
                fix: (fixer): ReturnType<ReportFixFunction> => {
                  const keyType = context.sourceCode.getText(constraint);
                  const valueType = node.typeAnnotation
                    ? context.sourceCode.getText(node.typeAnnotation)
                    : 'any';

                  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, '', '');
        },
      }),
    };
  }

findParentDeclaration(node: TSESTree.Node): TSESTree.TSTypeAliasDeclaration | undefined

Parameters:

  • node TSESTree.Node

Returns: TSESTree.TSTypeAliasDeclaration | undefined

Calls:

  • findParentDeclaration
Code
function findParentDeclaration(
  node: TSESTree.Node,
): TSESTree.TSTypeAliasDeclaration | undefined {
  if (node.parent && node.parent.type !== AST_NODE_TYPES.TSTypeAnnotation) {
    if (node.parent.type === AST_NODE_TYPES.TSTypeAliasDeclaration) {
      return node.parent;
    }
    return findParentDeclaration(node.parent);
  }
  return undefined;
}

isDeeplyReferencingType(node: TSESTree.Node, superVar: ScopeVariable, visited: Set<TSESTree.Node>): boolean

Parameters:

  • node TSESTree.Node
  • superVar ScopeVariable
  • visited Set<TSESTree.Node>

Returns: boolean

Calls:

  • visited.has
  • visited.add
  • node.members.some
  • isDeeplyReferencingType
  • [node.indexType, node.objectType].some
  • [ node.checkType, node.extendsType, node.falseType, node.trueType, ].some
  • node.types.some
  • node.body.body.some
  • node.params.some
  • superVar.references.some
  • isNodeEqual (from ../util)
  • ASTUtils.findVariable
  • refVar.defs.some

Internal Comments:

// something on the chain is circular but it's not the reference being checked
// check if the identifier is a reference of the type being checked
// otherwise, follow its definition(s) (x2)

Code
function isDeeplyReferencingType(
  node: TSESTree.Node,
  superVar: ScopeVariable,
  visited: Set<TSESTree.Node>,
): boolean {
  if (visited.has(node)) {
    // something on the chain is circular but it's not the reference being checked
    return false;
  }

  visited.add(node);

  switch (node.type) {
    case AST_NODE_TYPES.TSTypeLiteral:
      return node.members.some(member =>
        isDeeplyReferencingType(member, superVar, visited),
      );
    case AST_NODE_TYPES.TSTypeAliasDeclaration:
      return isDeeplyReferencingType(node.typeAnnotation, superVar, visited);
    case AST_NODE_TYPES.TSIndexedAccessType:
      return [node.indexType, node.objectType].some(type =>
        isDeeplyReferencingType(type, superVar, visited),
      );
    case AST_NODE_TYPES.TSMappedType:
      if (node.typeAnnotation) {
        return isDeeplyReferencingType(node.typeAnnotation, superVar, visited);
      }

      break;
    case AST_NODE_TYPES.TSConditionalType:
      return [
        node.checkType,
        node.extendsType,
        node.falseType,
        node.trueType,
      ].some(type => isDeeplyReferencingType(type, superVar, visited));
    case AST_NODE_TYPES.TSUnionType:
    case AST_NODE_TYPES.TSIntersectionType:
      return node.types.some(type =>
        isDeeplyReferencingType(type, superVar, visited),
      );
    case AST_NODE_TYPES.TSInterfaceDeclaration:
      return node.body.body.some(type =>
        isDeeplyReferencingType(type, superVar, visited),
      );
    case AST_NODE_TYPES.TSTypeAnnotation:
      return isDeeplyReferencingType(node.typeAnnotation, superVar, visited);
    case AST_NODE_TYPES.TSIndexSignature: {
      if (node.typeAnnotation) {
        return isDeeplyReferencingType(node.typeAnnotation, superVar, visited);
      }
      break;
    }
    case AST_NODE_TYPES.TSTypeParameterInstantiation: {
      return node.params.some(param =>
        isDeeplyReferencingType(param, superVar, visited),
      );
    }
    case AST_NODE_TYPES.TSTypeReference: {
      if (isDeeplyReferencingType(node.typeName, superVar, visited)) {
        return true;
      }

      if (
        node.typeArguments &&
        isDeeplyReferencingType(node.typeArguments, superVar, visited)
      ) {
        return true;
      }

      break;
    }
    case AST_NODE_TYPES.Identifier: {
      // check if the identifier is a reference of the type being checked
      if (superVar.references.some(ref => isNodeEqual(ref.identifier, node))) {
        return true;
      }

      // otherwise, follow its definition(s)
      const refVar = ASTUtils.findVariable(superVar.scope, node.name);

      if (refVar) {
        return refVar.defs.some(def =>
          isDeeplyReferencingType(def.node, superVar, visited),
        );
      }
    }
  }

  return false;
}

Internal helpers

Declared inside another function in this file.

hasUnpreservedComments(node: TSESTree.Node, preserved: (TSESTree.Node | undefined)[]): boolean

Parameters:

  • node TSESTree.Node
  • preserved (TSESTree.Node | undefined)[]

Returns: boolean

Calls:

  • context.sourceCode .getCommentsInside(node) .some
  • preserved.every
Code
function hasUnpreservedComments(
      node: TSESTree.Node,
      ...preserved: (TSESTree.Node | undefined)[]
    ): boolean {
      return context.sourceCode
        .getCommentsInside(node)
        .some(comment =>
          preserved.every(
            target =>
              target == null ||
              comment.range[0] < target.range[0] ||
              comment.range[1] > target.range[1],
          ),
        );
    }

checkMembers(…): void

Parameters:

  • members TSESTree.TypeElement[]
  • node TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeLiteral
  • parentId TSESTree.Identifier | undefined
  • prefix string
  • postfix string
  • safeFix boolean

Returns: void

Calls:

  • member.parameters.at
  • context.sourceCode.getScope
  • ASTUtils.findVariable
  • isDeeplyReferencingType
  • context.report
  • getFixOrSuggest (from ../util)
  • hasUnpreservedComments
  • context.sourceCode.getText
  • fixer.replaceText
Code
function checkMembers(
      members: TSESTree.TypeElement[],
      node: TSESTree.TSInterfaceDeclaration | TSESTree.TSTypeLiteral,
      parentId: TSESTree.Identifier | undefined,
      prefix: string,
      postfix: string,
      safeFix = true,
    ): void {
      if (members.length !== 1) {
        return;
      }
      const [member] = members;

      if (member.type !== AST_NODE_TYPES.TSIndexSignature) {
        return;
      }

      const parameter = member.parameters.at(0);
      if (parameter?.type !== AST_NODE_TYPES.Identifier) {
        return;
      }

      const keyType = parameter.typeAnnotation;
      if (!keyType) {
        return;
      }

      const valueType = member.typeAnnotation;
      if (!valueType) {
        return;
      }

      if (parentId) {
        const scope = context.sourceCode.getScope(parentId);
        const superVar = ASTUtils.findVariable(scope, parentId.name);

        if (
          superVar &&
          isDeeplyReferencingType(node, superVar, new Set([parentId]))
        ) {
          return;
        }
      }

      context.report({
        node,
        messageId: 'preferRecord',
        ...getFixOrSuggest({
          fixOrSuggest: !safeFix
            ? 'none'
            : hasUnpreservedComments(
                  node,
                  keyType.typeAnnotation,
                  valueType.typeAnnotation,
                )
              ? 'suggest'
              : 'fix',
          suggestion: {
            messageId: 'preferRecordSuggestion',
            fix: (fixer): TSESLint.RuleFix => {
              const key = context.sourceCode.getText(keyType.typeAnnotation);
              const value = context.sourceCode.getText(
                valueType.typeAnnotation,
              );
              const record = member.readonly
                ? `Readonly<Record<${key}, ${value}>>`
                : `Record<${key}, ${value}>`;
              return fixer.replaceText(node, `${prefix}${record}${postfix}`);
            },
          },
        }),
      });
    }

suggestion.fix(fixer: any): TSESLint.RuleFix

Parameters:

  • fixer any

Returns: TSESLint.RuleFix

Calls:

  • context.sourceCode.getText
  • fixer.replaceText
Code
(fixer): TSESLint.RuleFix => {
              const key = context.sourceCode.getText(keyType.typeAnnotation);
              const value = context.sourceCode.getText(
                valueType.typeAnnotation,
              );
              const record = member.readonly
                ? `Readonly<Record<${key}, ${value}>>`
                : `Record<${key}, ${value}>`;
              return fixer.replaceText(node, `${prefix}${record}${postfix}`);
            }

suggestion.fix(fixer: any): any

Parameters:

  • fixer any

Returns: any

Calls:

  • context.sourceCode.getText
  • fixer.replaceText
Code
fixer => {
                  const key = context.sourceCode.getText(params[0]);
                  const type = context.sourceCode.getText(params[1]);
                  return fixer.replaceText(node, `{ [key: ${key}]: ${type} }`);
                }

suggestion.fix(fixer: any): ReturnType<ReportFixFunction>

Parameters:

  • fixer any

Returns: ReturnType<ReportFixFunction>

Calls:

  • context.sourceCode.getText
  • fixer.replaceText
Code
(fixer): ReturnType<ReportFixFunction> => {
                  const keyType = context.sourceCode.getText(constraint);
                  const valueType = node.typeAnnotation
                    ? context.sourceCode.getText(node.typeAnnotation)
                    : 'any';

                  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);
                }

Type Aliases

MessageIds

type MessageIds = | 'preferIndexSignature'
  | 'preferIndexSignatureSuggestion'
  | 'preferRecord'
  | 'preferRecordSuggestion';

Options

type Options = ['index-signature' | 'record'];

Generated by Syntax Scribe