Skip to content

feat(eslint-plugin): add prefer-record-type-annotation rule #11411

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-record-type-annotation.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
description: 'Enforce explicit Record<K, V> type annotations for object literals.'
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/prefer-record-type-annotation** for documentation.

This rule enforces explicit `Record<K, V>` type annotations for object literals that could benefit from them. It helps improve type safety and code clarity by making the intended structure of objects explicit.

## Rule Details

When you have object literals with multiple properties of the same type, it can be beneficial to explicitly type them as `Record<K, V>` to:

1. **Improve type safety** - TypeScript can better catch errors when the structure is explicit
2. **Enhance readability** - Other developers immediately understand the intended structure
3. **Enable better IntelliSense** - IDEs can provide better autocomplete and suggestions
4. **Facilitate refactoring** - Changes to the value type are easier to make consistently

<Tabs>
<TabItem value="❌ Incorrect">

```ts
const statusMessages = {
success: "Operation completed",
error: "An error occurred",
pending: "Operation pending",
};

const userScores = {
alice: 100,
bob: 95,
charlie: 87,
};
```

</TabItem>
<TabItem value="✅ Correct">

```ts
const statusMessages: Record<'success' | 'error' | 'pending', string> = {
success: "Operation completed",
error: "An error occurred",
pending: "Operation pending",
};

const userScores: Record<'alice' | 'bob' | 'charlie', number> = {
alice: 100,
bob: 95,
charlie: 87,
};
```

</TabItem>
</Tabs>

## Configuration

This rule has no configurable options. It triggers for object literals that have consistent value types.

## When Not To Use It

You might want to disable this rule if:

- You prefer to rely on TypeScript's type inference for object literals
- Your team has a different style preference for object typing
- You're working with dynamic objects where explicit typing would be overly restrictive
- You have many small objects that don't benefit from explicit Record typing

## Examples

### Basic Usage

<Tabs>
<TabItem value="❌ Incorrect">

```ts
// Even small objects trigger the rule
const environment = {
dev: "development",
prod: "production",
};

// String values
const statusMessages = {
success: "Operation completed successfully",
error: "An error occurred",
pending: "Operation is pending",
};

// Number values
const httpStatusCodes = {
ok: 200,
notFound: 404,
serverError: 500,
};

// Boolean values
const featureFlags = {
newDesign: true,
betaFeatures: false,
analytics: true,
};
```

</TabItem>
<TabItem value="✅ Correct">

```ts
// Even small objects need explicit typing
const environment: Record<'dev' | 'prod', string> = {
dev: "development",
prod: "production",
};

// String values
const statusMessages: Record<'success' | 'error' | 'pending', string> = {
success: "Operation completed successfully",
error: "An error occurred",
pending: "Operation is pending",
};

// Number values
const httpStatusCodes: Record<'ok' | 'notFound' | 'serverError', number> = {
ok: 200,
notFound: 404,
serverError: 500,
};

// Boolean values
const featureFlags: Record<'newDesign' | 'betaFeatures' | 'analytics', boolean> = {
newDesign: true,
betaFeatures: false,
analytics: true,
};
```

</TabItem>
</Tabs>

### Cases That Won't Trigger The Rule

```ts
// Already has explicit type annotation
const typed: Record<string, string> = { a: "1", b: "2", c: "3" };

// Mixed value types
const mixed = { str: "hello", num: 42, bool: true };

// Complex properties (computed keys, methods)
const complex = {
a: "1",
b: "2",
c: "3",
['computed']: "4",
method() { return "value"; }
};

// Non-literal values
const variable = "test";
const dynamic = { a: variable, b: variable, c: variable };
```

## Related Rules

- [`consistent-indexed-object-style`](https://typescript-eslint.io/rules/consistent-indexed-object-style) - Enforces consistent usage of Record vs index signatures
- [`typedef`](https://typescript-eslint.io/rules/typedef) - Requires type annotations in various places
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import preferOptionalChain from './prefer-optional-chain';
import preferPromiseRejectErrors from './prefer-promise-reject-errors';
import preferReadonly from './prefer-readonly';
import preferReadonlyParameterTypes from './prefer-readonly-parameter-types';
import preferRecordTypeAnnotation from './prefer-record-type-annotation';
import preferReduceTypeParameter from './prefer-reduce-type-parameter';
import preferRegexpExec from './prefer-regexp-exec';
import preferReturnThisType from './prefer-return-this-type';
Expand Down Expand Up @@ -245,6 +246,7 @@ const rules = {
'prefer-promise-reject-errors': preferPromiseRejectErrors,
'prefer-readonly': preferReadonly,
'prefer-readonly-parameter-types': preferReadonlyParameterTypes,
'prefer-record-type-annotation': preferRecordTypeAnnotation,
'prefer-reduce-type-parameter': preferReduceTypeParameter,
'prefer-regexp-exec': preferRegexpExec,
'prefer-return-this-type': preferReturnThisType,
Expand Down
210 changes: 210 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-record-type-annotation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';

import { AST_NODE_TYPES } from '@typescript-eslint/utils';

import { createRule } from '../util';

export type MessageIds = 'preferRecordAnnotation';

export default createRule<[], MessageIds>({
name: 'prefer-record-type-annotation',
meta: {
type: 'suggestion',
docs: {
description:
'Enforce explicit Record<K, V> type annotations for object literals',
recommended: 'stylistic',
},
fixable: 'code',
messages: {
preferRecordAnnotation:
'Object literal should have explicit Record<{{keyType}}, {{valueType}}> type annotation.',
},
schema: [],
},
defaultOptions: [],
create(context) {

/**
* Determines if all values in an object expression have compatible types
* Returns the unified type name if compatible, null otherwise
*/
function getUnifiedValueType(
properties: TSESTree.ObjectExpressionProperty[],
): string | null {
if (properties.length === 0) {
return null;
}

// For now, we'll focus on literals and simple cases
let unifiedType: string | null = null;

for (const property of properties) {
if (
property.type !== AST_NODE_TYPES.Property ||
property.computed ||
property.method ||
property.kind !== 'init'
) {
return null; // Skip complex properties
}

const valueType = inferValueType(property.value);
if (valueType === null) {
return null; // Can't determine type
}

if (unifiedType === null) {
unifiedType = valueType;
} else if (unifiedType !== valueType) {
// For mixed types, we could use union types, but for simplicity
// let's just handle cases where all values have the same type
if (
(unifiedType === 'string' && valueType === 'string') ||
(unifiedType === 'number' && valueType === 'number') ||
(unifiedType === 'boolean' && valueType === 'boolean')
) {
continue;
}
return null;
}
}

return unifiedType;
}

/**
* Infer the TypeScript type from a value expression
*/
function inferValueType(value: TSESTree.Expression): string | null {
switch (value.type) {
case AST_NODE_TYPES.Literal:
if (typeof value.value === 'string') return 'string';
if (typeof value.value === 'number') return 'number';
if (typeof value.value === 'boolean') return 'boolean';
if (value.value === null) return 'null';
break;
case AST_NODE_TYPES.TemplateLiteral:
return 'string';
case AST_NODE_TYPES.UnaryExpression:
if (value.operator === '-' && value.argument.type === AST_NODE_TYPES.Literal) {
return 'number';
}
break;
case AST_NODE_TYPES.Identifier:
// Could be a variable, but we can't easily determine its type
// For now, skip these cases
return null;
case AST_NODE_TYPES.ArrayExpression:
return 'any[]'; // Could be more specific, but this is a start
case AST_NODE_TYPES.ObjectExpression:
return 'object';
}
return null;
}

/**
* Generate Record type annotation text
*/
function generateRecordType(
keys: string[],
valueType: string,
): string {
const keyUnion = keys.map(key => `'${key}'`).join(' | ');
return `Record<${keyUnion}, ${valueType}>`;
}

/**
* Extract property keys from object expression
*/
function extractPropertyKeys(
properties: TSESTree.ObjectExpressionProperty[],
): string[] | null {
const keys: string[] = [];

for (const property of properties) {
if (
property.type !== AST_NODE_TYPES.Property ||
property.computed ||
property.method ||
property.kind !== 'init'
) {
return null; // Skip complex properties
}

if (property.key.type === AST_NODE_TYPES.Identifier) {
keys.push(property.key.name);
} else if (
property.key.type === AST_NODE_TYPES.Literal &&
typeof property.key.value === 'string'
) {
keys.push(property.key.value);
} else {
return null; // Can't handle computed or complex keys
}
}

return keys;
}

return {
VariableDeclarator(node): void {
// Skip if already has type annotation
if (node.id.typeAnnotation) {
return;
}

// Only handle simple identifier declarations
if (node.id.type !== AST_NODE_TYPES.Identifier) {
return;
}

// Only handle object expression initializers
if (!node.init || node.init.type !== AST_NODE_TYPES.ObjectExpression) {
return;
}

const objectExpression = node.init;
const properties = objectExpression.properties;

// Only handle simple properties (not spread, methods, etc.)
const simpleProperties = properties.filter(
(prop): prop is TSESTree.Property =>
prop.type === AST_NODE_TYPES.Property &&
!prop.computed &&
!prop.method &&
prop.kind === 'init',
);

if (simpleProperties.length !== properties.length) {
return; // Has complex properties, skip
}

const keys = extractPropertyKeys(simpleProperties);
if (!keys) {
return;
}

const valueType = getUnifiedValueType(simpleProperties);
if (!valueType) {
return;
}

const keyType = keys.map(key => `'${key}'`).join(' | ');
const recordType = generateRecordType(keys, valueType);

context.report({
node: node.id,
messageId: 'preferRecordAnnotation',
data: {
keyType,
valueType,
},
fix: (fixer): TSESLint.RuleFix => {
return fixer.insertTextAfter(node.id, `: ${recordType}`);
},
});
},
};
},
});
Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy