Skip to content

feat(eslint-plugin): back-port new rules around empty object types from v8 #9443

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

Merged
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
177 changes: 177 additions & 0 deletions packages/eslint-plugin/docs/rules/no-empty-object-type.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
description: 'Disallow accidentally using the "empty object" type.'
---

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/no-empty-object-type** for documentation.

The `{}`, or "empty object" type in TypeScript is a common source of confusion for developers unfamiliar with TypeScript's structural typing.
`{}` represents any _non-nullish value_, including literals like `0` and `""`:

```ts
let anyNonNullishValue: {} = 'Intentionally allowed by TypeScript.';
```

Often, developers writing `{}` actually mean either:

- `object`: representing any _object_ value
- `unknown`: representing any value at all, including `null` and `undefined`

In other words, the "empty object" type `{}` really means _"any value that is defined"_.
That includes arrays, class instances, functions, and primitives such as `string` and `symbol`.

To avoid confusion around the `{}` type allowing any _non-nullish value_, this rule bans usage of the `{}` type.
That includes interfaces and object type aliases with no fields.

:::tip
If you do have a use case for an API allowing `{}`, you can always configure the [rule's options](#options), use an [ESLint disable comment](https://eslint.org/docs/latest/use/configure/rules#using-configuration-comments-1), or [disable the rule in your ESLint config](https://eslint.org/docs/latest/use/configure/rules#using-configuration-files-1).
:::

Note that this rule does not report on:

- `{}` as a type constituent in an intersection type (e.g. types like TypeScript's built-in `type NonNullable<T> = T & {}`), as this can be useful in type system operations.
- Interfaces that extend from multiple other interfaces.

## Examples

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

```ts
let anyObject: {};
let anyValue: {};

interface AnyObjectA {}
interface AnyValueA {}

type AnyObjectB = {};
type AnyValueB = {};
```

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

```ts
let anyObject: object;
let anyValue: unknown;

type AnyObjectA = object;
type AnyValueA = unknown;

type AnyObjectB = object;
type AnyValueB = unknown;

let objectWith: { property: boolean };

interface InterfaceWith {
property: boolean;
}

type TypeWith = { property: boolean };
```

</TabItem>
</Tabs>

## Options

By default, this rule flags both interfaces and object types.

### `allowInterfaces`

Whether to allow empty interfaces, as one of:

- `'always'`: to always allow interfaces with no fields
- `'never'` _(default)_: to never allow interfaces with no fields
- `'with-single-extends'`: to allow empty interfaces that `extend` from a single base interface

Examples of code for this rule with `{ allowInterfaces: 'with-single-extends' }`:

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

```ts option='{ "allowInterfaces": "with-single-extends" }'
interface Foo {}
```

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

```ts option='{ "allowInterfaces": "with-single-extends" }'
interface Base {
value: boolean;
}

interface Derived extends Base {}
```

</TabItem>
</Tabs>

### `allowObjectTypes`

Whether to allow empty object type literals, as one of:

- `'always'`: to always allow object type literals with no fields
- `'never'` _(default)_: to never allow object type literals with no fields

Examples of code for this rule with `{ allowObjectTypes: 'always' }`:

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

```ts option='{ "allowObjectTypes": "always" }'
interface Base {}
```

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

```ts option='{ "allowObjectTypes": "always" }'
type Base = {};
```

</TabItem>
</Tabs>

### `allowWithName`

A stringified regular expression to allow interfaces and object type aliases with the configured name.
This can be useful if your existing code style includes a pattern of declaring empty types with `{}` instead of `object`.

Examples of code for this rule with `{ allowWithName: 'Props$' }`:

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

```ts option='{ "allowWithName": "Props$" }'
interface InterfaceValue {}

type TypeValue = {};
```

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

```ts option='{ "allowWithName": "Props$" }'
interface InterfaceProps {}

type TypeProps = {};
```

</TabItem>
</Tabs>

## When Not To Use It

If your code commonly needs to represent the _"any non-nullish value"_ type, this rule may not be for you.
Projects that extensively use type operations such as conditional types and mapped types oftentimes benefit from disabling this rule.

## Further Reading

- [Enhancement: [ban-types] Split the {} ban into a separate, better-phrased rule](https://github.com/typescript-eslint/typescript-eslint/issues/8700)
- [The Empty Object Type in TypeScript](https://www.totaltypescript.com/the-empty-object-type-in-typescript)
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export = {
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-empty-object-type': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-extra-non-null-assertion': 'error',
'@typescript-eslint/no-extraneous-class': 'error',
Expand Down
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 @@ -47,6 +47,7 @@ import noDuplicateTypeConstituents from './no-duplicate-type-constituents';
import noDynamicDelete from './no-dynamic-delete';
import noEmptyFunction from './no-empty-function';
import noEmptyInterface from './no-empty-interface';
import noEmptyObjectType from './no-empty-object-type';
import noExplicitAny from './no-explicit-any';
import noExtraNonNullAssertion from './no-extra-non-null-assertion';
import noExtraParens from './no-extra-parens';
Expand Down Expand Up @@ -193,6 +194,7 @@ export default {
'no-dynamic-delete': noDynamicDelete,
'no-empty-function': noEmptyFunction,
'no-empty-interface': noEmptyInterface,
'no-empty-object-type': noEmptyObjectType,
'no-explicit-any': noExplicitAny,
'no-extra-non-null-assertion': noExtraNonNullAssertion,
'no-extra-parens': noExtraParens,
Expand Down
186 changes: 186 additions & 0 deletions packages/eslint-plugin/src/rules/no-empty-object-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import type { TSESLint } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';

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

export type AllowInterfaces = 'always' | 'never' | 'with-single-extends';

export type AllowObjectTypes = 'always' | 'never';

export type Options = [
{
allowInterfaces?: AllowInterfaces;
allowObjectTypes?: AllowObjectTypes;
allowWithName?: string;
},
];

export type MessageIds =
| 'noEmptyInterface'
| 'noEmptyObject'
| 'noEmptyInterfaceWithSuper'
| 'replaceEmptyInterface'
| 'replaceEmptyInterfaceWithSuper'
| 'replaceEmptyObjectType';

const noEmptyMessage = (emptyType: string): string =>
[
`${emptyType} allows any non-nullish value, including literals like \`0\` and \`""\`.`,
"- If that's what you want, disable this lint rule with an inline comment or configure the '{{ option }}' rule option.",
'- If you want a type meaning "any object", you probably want `object` instead.',
'- If you want a type meaning "any value", you probably want `unknown` instead.',
].join('\n');

export default createRule<Options, MessageIds>({
name: 'no-empty-object-type',
meta: {
type: 'suggestion',
docs: {
description: 'Disallow accidentally using the "empty object" type',
},
hasSuggestions: true,
messages: {
noEmptyInterface: noEmptyMessage('An empty interface declaration'),
noEmptyObject: noEmptyMessage('The `{}` ("empty object") type'),
noEmptyInterfaceWithSuper:
'An interface declaring no members is equivalent to its supertype.',
replaceEmptyInterface: 'Replace empty interface with `{{replacement}}`.',
replaceEmptyInterfaceWithSuper:
'Replace empty interface with a type alias.',
replaceEmptyObjectType: 'Replace `{}` with `{{replacement}}`.',
},
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
allowInterfaces: {
enum: ['always', 'never', 'with-single-extends'],
type: 'string',
},
allowObjectTypes: {
enum: ['always', 'in-type-alias-with-name', 'never'],
type: 'string',
},
allowWithName: {
type: 'string',
},
},
},
],
},
defaultOptions: [
{
allowInterfaces: 'never',
allowObjectTypes: 'never',
},
],
create(context, [{ allowInterfaces, allowWithName, allowObjectTypes }]) {
const allowWithNameTester = allowWithName
? new RegExp(allowWithName, 'u')
: undefined;

return {
...(allowInterfaces !== 'always' && {
TSInterfaceDeclaration(node): void {
if (allowWithNameTester?.test(node.id.name)) {
return;
}

const extend = node.extends;
if (
node.body.body.length !== 0 ||
(extend.length === 1 &&
allowInterfaces === 'with-single-extends') ||
extend.length > 1
) {
return;
}

const scope = context.sourceCode.getScope(node);

const mergedWithClassDeclaration = scope.set
.get(node.id.name)
?.defs.some(
def => def.node.type === AST_NODE_TYPES.ClassDeclaration,
);

if (extend.length === 0) {
context.report({
data: { option: 'allowInterfaces' },
node: node.id,
messageId: 'noEmptyInterface',
...(!mergedWithClassDeclaration && {
suggest: ['object', 'unknown'].map(replacement => ({
data: { replacement },
fix(fixer): TSESLint.RuleFix {
const id = context.sourceCode.getText(node.id);
const typeParam = node.typeParameters
? context.sourceCode.getText(node.typeParameters)
: '';

return fixer.replaceText(
node,
`type ${id}${typeParam} = ${replacement}`,
);
},
messageId: 'replaceEmptyInterface',
})),
}),
});
return;
}

context.report({
node: node.id,
messageId: 'noEmptyInterfaceWithSuper',
...(!mergedWithClassDeclaration && {
suggest: [
{
fix(fixer): TSESLint.RuleFix {
const extended = context.sourceCode.getText(extend[0]);
const id = context.sourceCode.getText(node.id);
const typeParam = node.typeParameters
? context.sourceCode.getText(node.typeParameters)
: '';

return fixer.replaceText(
node,
`type ${id}${typeParam} = ${extended}`,
);
},
messageId: 'replaceEmptyInterfaceWithSuper',
},
],
}),
});
},
}),
...(allowObjectTypes !== 'always' && {
TSTypeLiteral(node): void {
if (
node.members.length ||
node.parent.type === AST_NODE_TYPES.TSIntersectionType ||
(allowWithNameTester &&
node.parent.type === AST_NODE_TYPES.TSTypeAliasDeclaration &&
allowWithNameTester.test(node.parent.id.name))
) {
return;
}

context.report({
data: { option: 'allowObjectTypes' },
messageId: 'noEmptyObject',
node,
suggest: ['object', 'unknown'].map(replacement => ({
data: { replacement },
messageId: 'replaceEmptyObjectType',
fix: (fixer): TSESLint.RuleFix =>
fixer.replaceText(node, replacement),
})),
});
},
}),
};
},
});
Loading
Loading