From bcfbb03082884c4766845c705d8ef0133a08fa57 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 23 Apr 2024 15:13:08 -0400 Subject: [PATCH 01/21] feat(eslint-plugin): split no-empty-object-type rule out from ban-types rule --- .../eslint-plugin/docs/rules/ban-types.mdx | 9 -- .../docs/rules/no-empty-interface.mdx | 4 + .../docs/rules/no-empty-object-type.mdx | 65 ++++++++++++++ packages/eslint-plugin/src/configs/all.ts | 1 + .../src/configs/recommended-type-checked.ts | 1 + .../eslint-plugin/src/configs/recommended.ts | 1 + .../src/configs/strict-type-checked.ts | 1 + packages/eslint-plugin/src/configs/strict.ts | 1 + packages/eslint-plugin/src/rules/ban-types.ts | 31 +------ packages/eslint-plugin/src/rules/index.ts | 2 + .../src/rules/no-empty-object-type.ts | 45 ++++++++++ .../ban-types.shot | 26 +----- .../no-empty-object-type.shot | 34 ++++++++ .../tests/rules/ban-types.test.ts | 38 --------- .../tests/rules/no-empty-object-type.test.ts | 85 +++++++++++++++++++ .../no-empty-object-type.shot | 14 +++ packages/typescript-eslint/src/configs/all.ts | 1 + .../src/configs/recommended-type-checked.ts | 1 + .../src/configs/recommended.ts | 1 + .../src/configs/strict-type-checked.ts | 1 + .../typescript-eslint/src/configs/strict.ts | 1 + 21 files changed, 265 insertions(+), 98 deletions(-) create mode 100644 packages/eslint-plugin/docs/rules/no-empty-object-type.mdx create mode 100644 packages/eslint-plugin/src/rules/no-empty-object-type.ts create mode 100644 packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot create mode 100644 packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts create mode 100644 packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot diff --git a/packages/eslint-plugin/docs/rules/ban-types.mdx b/packages/eslint-plugin/docs/rules/ban-types.mdx index da30f495911..98ce57edd71 100644 --- a/packages/eslint-plugin/docs/rules/ban-types.mdx +++ b/packages/eslint-plugin/docs/rules/ban-types.mdx @@ -36,9 +36,6 @@ const func: Function = () => 1; // use safer object types const lowerObj: Object = {}; const capitalObj: Object = { a: 'string' }; - -const curly1: {} = 1; -const curly2: {} = { a: 'string' }; ``` @@ -58,9 +55,6 @@ const func: () => number = () => 1; // use safer object types const lowerObj: object = {}; const capitalObj: { a: string } = { a: 'string' }; - -const curly1: number = 1; -const curly2: Record<'a', string> = { a: 'string' }; ``` @@ -74,9 +68,6 @@ The default options provide a set of "best practices", intended to provide safet - Avoid the `Function` type, as it provides little safety for the following reasons: - It provides no type safety when calling the value, which means it's easy to provide the wrong arguments. - It accepts class declarations, which will fail when called, as they are called without the `new` keyword. -- Avoid the `Object` and `{}` types, as they mean "any non-nullish value". - - This is a point of confusion for many developers, who think it means "any object type". - - See [this comment for more information](https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-675156492).
Default Options diff --git a/packages/eslint-plugin/docs/rules/no-empty-interface.mdx b/packages/eslint-plugin/docs/rules/no-empty-interface.mdx index ad240237ddb..aec49748124 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-interface.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-interface.mdx @@ -61,3 +61,7 @@ interface Baz extends Foo, Bar {} ## When Not To Use It If you don't care about having empty/meaningless interfaces, then you will not need this rule. + +## Related To + +- [`no-empty-object-type`](./no-empty-object-type.mdx) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx new file mode 100644 index 00000000000..ffb2cb76324 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -0,0 +1,65 @@ +--- +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 _other than `null` and `undefined`_ + +To avoid confusion around the `{}` type allowing non-object values, this rule bans usage of the `{}` type. + +:::tip +If you do have a use case for an API allowing any _non-nullish value_, you can always 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). +::: + +## Examples + + + + +```ts +let anyObject: {}; +let anyValue: {}; +let emptyObject: {}; +``` + + + + +```ts +let anyObject: object; +let anyValue: unknown; +let emptyObject: Record; +``` + + + + +## 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) + +## Related To + +- [`no-empty-interface`](./no-empty-interface.mdx) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 71db2c523b2..a9eb685a9c3 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -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', diff --git a/packages/eslint-plugin/src/configs/recommended-type-checked.ts b/packages/eslint-plugin/src/configs/recommended-type-checked.ts index 20993a06640..858eb655509 100644 --- a/packages/eslint-plugin/src/configs/recommended-type-checked.ts +++ b/packages/eslint-plugin/src/configs/recommended-type-checked.ts @@ -18,6 +18,7 @@ export = { '@typescript-eslint/no-base-to-string': 'error', '@typescript-eslint/no-duplicate-enum-values': 'error', '@typescript-eslint/no-duplicate-type-constituents': '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-floating-promises': 'error', diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts index 58f31702ada..c93e38eabb2 100644 --- a/packages/eslint-plugin/src/configs/recommended.ts +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -15,6 +15,7 @@ export = { 'no-array-constructor': 'off', '@typescript-eslint/no-array-constructor': 'error', '@typescript-eslint/no-duplicate-enum-values': '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-misused-new': 'error', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked.ts b/packages/eslint-plugin/src/configs/strict-type-checked.ts index 5b00d236984..91fd8b1589d 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked.ts @@ -24,6 +24,7 @@ export = { '@typescript-eslint/no-duplicate-enum-values': 'error', '@typescript-eslint/no-duplicate-type-constituents': 'error', '@typescript-eslint/no-dynamic-delete': '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', diff --git a/packages/eslint-plugin/src/configs/strict.ts b/packages/eslint-plugin/src/configs/strict.ts index 598b3246e27..ae000f72d3f 100644 --- a/packages/eslint-plugin/src/configs/strict.ts +++ b/packages/eslint-plugin/src/configs/strict.ts @@ -19,6 +19,7 @@ export = { '@typescript-eslint/no-array-constructor': 'error', '@typescript-eslint/no-duplicate-enum-values': 'error', '@typescript-eslint/no-dynamic-delete': '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', diff --git a/packages/eslint-plugin/src/rules/ban-types.ts b/packages/eslint-plugin/src/rules/ban-types.ts index da2d79716a3..06c9daa62d4 100644 --- a/packages/eslint-plugin/src/rules/ban-types.ts +++ b/packages/eslint-plugin/src/rules/ban-types.ts @@ -73,7 +73,10 @@ const defaultTypes: Types = { message: 'Use bigint instead', fixWith: 'bigint', }, - + Object: { + message: 'Use object instead', + fixWith: 'object', + }, Function: { message: [ 'The `Function` type accepts any function-like value.', @@ -82,32 +85,6 @@ const defaultTypes: Types = { 'If you are expecting the function to accept certain arguments, you should explicitly define the function shape.', ].join('\n'), }, - - // object typing - Object: { - message: [ - 'The `Object` type actually means "any non-nullish value", so it is marginally better than `unknown`.', - '- 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.', - '- If you really want a type meaning "any non-nullish value", you probably want `NonNullable` instead.', - ].join('\n'), - suggest: ['object', 'unknown', 'NonNullable'], - }, - '{}': { - message: [ - '`{}` actually means "any non-nullish value".', - '- 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.', - '- If you want a type meaning "empty object", you probably want `Record` instead.', - '- If you really want a type meaning "any non-nullish value", you probably want `NonNullable` instead.', - ].join('\n'), - suggest: [ - 'object', - 'unknown', - 'Record', - 'NonNullable', - ], - }, }; export const TYPE_KEYWORDS = { diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index ae1da2b6d43..c580027d059 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -36,6 +36,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 noExtraneousClass from './no-extraneous-class'; @@ -160,6 +161,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-extraneous-class': noExtraneousClass, diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts new file mode 100644 index 00000000000..d4d784477b9 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -0,0 +1,45 @@ +import type { TSESLint } from '@typescript-eslint/utils'; + +import { createRule } from '../util'; + +export default createRule({ + name: 'no-empty-object-type', + meta: { + type: 'suggestion', + docs: { + description: 'Disallow accidentally using the "empty object" type', + recommended: 'recommended', + }, + hasSuggestions: true, + messages: { + banEmptyObjectType: [ + 'The `{}` ("empty object") type 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 in your ESLint config.", + '- 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.', + '- If you want a type meaning "empty object", you probably want `Record` instead.', + ].join('\n'), + replaceEmptyObjectType: 'Replace `{}` with `{{replacement}}`.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'TSTypeLiteral[members.length=0]'(node): void { + context.report({ + messageId: 'banEmptyObjectType', + node, + suggest: ['object', 'unknown', 'Record'].map( + replacement => ({ + data: { replacement }, + messageId: 'replaceEmptyObjectType', + fix: (fixer): TSESLint.RuleFix => + fixer.replaceText(node, replacement), + }), + ), + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/ban-types.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/ban-types.shot index f96ac650ffa..6644fd985d9 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/ban-types.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/ban-types.shot @@ -24,28 +24,9 @@ const func: Function = () => 1; // use safer object types const lowerObj: Object = {}; - ~~~~~~ Don't use \`Object\` as a type. The \`Object\` type actually means "any non-nullish value", so it is marginally better than \`unknown\`. - - 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. - - If you really want a type meaning "any non-nullish value", you probably want \`NonNullable\` instead. + ~~~~~~ Don't use \`Object\` as a type. Use object instead const capitalObj: Object = { a: 'string' }; - ~~~~~~ Don't use \`Object\` as a type. The \`Object\` type actually means "any non-nullish value", so it is marginally better than \`unknown\`. - - 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. - - If you really want a type meaning "any non-nullish value", you probably want \`NonNullable\` instead. - -const curly1: {} = 1; - ~~ Don't use \`{}\` as a type. \`{}\` actually means "any non-nullish value". - - 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. - - If you want a type meaning "empty object", you probably want \`Record\` instead. - - If you really want a type meaning "any non-nullish value", you probably want \`NonNullable\` instead. -const curly2: {} = { a: 'string' }; - ~~ Don't use \`{}\` as a type. \`{}\` actually means "any non-nullish value". - - 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. - - If you want a type meaning "empty object", you probably want \`Record\` instead. - - If you really want a type meaning "any non-nullish value", you probably want \`NonNullable\` instead. + ~~~~~~ Don't use \`Object\` as a type. Use object instead " `; @@ -65,8 +46,5 @@ const func: () => number = () => 1; // use safer object types const lowerObj: object = {}; const capitalObj: { a: string } = { a: 'string' }; - -const curly1: number = 1; -const curly2: Record<'a', string> = { a: 'string' }; " `; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot new file mode 100644 index 00000000000..1b1eadc79d0 --- /dev/null +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 1`] = ` +"Incorrect + +let anyObject: {}; + ~~ The \`{}\` ("empty object") type 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 in your ESLint config. + - 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. + - If you want a type meaning "empty object", you probably want \`Record\` instead. +let anyValue: {}; + ~~ The \`{}\` ("empty object") type 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 in your ESLint config. + - 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. + - If you want a type meaning "empty object", you probably want \`Record\` instead. +let emptyObject: {}; + ~~ The \`{}\` ("empty object") type 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 in your ESLint config. + - 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. + - If you want a type meaning "empty object", you probably want \`Record\` instead. +" +`; + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 2`] = ` +"Correct + +let anyObject: object; +let anyValue: unknown; +let emptyObject: Record; +" +`; diff --git a/packages/eslint-plugin/tests/rules/ban-types.test.ts b/packages/eslint-plugin/tests/rules/ban-types.test.ts index 5f8d72ab0f7..046ac290a63 100644 --- a/packages/eslint-plugin/tests/rules/ban-types.test.ts +++ b/packages/eslint-plugin/tests/rules/ban-types.test.ts @@ -137,44 +137,6 @@ ruleTester.run('ban-types', rule, { ], options, }, - { - code: 'let a: Object;', - output: null, - errors: [ - { - messageId: 'bannedTypeMessage', - data: { - name: 'Object', - customMessage: [ - ' The `Object` type actually means "any non-nullish value", so it is marginally better than `unknown`.', - '- 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.', - '- If you really want a type meaning "any non-nullish value", you probably want `NonNullable` instead.', - ].join('\n'), - }, - line: 1, - column: 8, - suggestions: [ - { - messageId: 'bannedTypeReplacement', - data: { name: 'Object', replacement: 'object' }, - output: 'let a: object;', - }, - { - messageId: 'bannedTypeReplacement', - data: { name: 'Object', replacement: 'unknown' }, - output: 'let a: unknown;', - }, - { - messageId: 'bannedTypeReplacement', - data: { name: 'Object', replacement: 'NonNullable' }, - output: 'let a: NonNullable;', - }, - ], - }, - ], - options: [{}], - }, { code: 'let aa: Foo;', output: null, diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts new file mode 100644 index 00000000000..fef527edaba --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -0,0 +1,85 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-empty-object-type'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-empty-object-type', rule, { + valid: [ + 'let value: object;', + 'let value: Object;', + 'let value: { inner: true };', + ], + invalid: [ + { + code: 'let value: {};', + errors: [ + { + column: 12, + line: 1, + endColumn: 14, + endLine: 1, + messageId: 'banEmptyObjectType', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: unknown;', + }, + { + data: { replacement: 'Record' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: Record;', + }, + ], + }, + ], + }, + { + code: ` +let value: { + /* ... */ +}; + `, + errors: [ + { + line: 2, + endLine: 4, + column: 12, + endColumn: 2, + messageId: 'banEmptyObjectType', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: ` +let value: object; + `, + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: ` +let value: unknown; + `, + }, + { + data: { replacement: 'Record' }, + messageId: 'replaceEmptyObjectType', + output: ` +let value: Record; + `, + }, + ], + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot new file mode 100644 index 00000000000..8f658c6d922 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes no-empty-object-type 1`] = ` +" +# SCHEMA: + +[] + + +# TYPES: + +/** No options declared */ +type Options = [];" +`; diff --git a/packages/typescript-eslint/src/configs/all.ts b/packages/typescript-eslint/src/configs/all.ts index e555dd197e5..5341ff26fd2 100644 --- a/packages/typescript-eslint/src/configs/all.ts +++ b/packages/typescript-eslint/src/configs/all.ts @@ -64,6 +64,7 @@ export default ( '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', diff --git a/packages/typescript-eslint/src/configs/recommended-type-checked.ts b/packages/typescript-eslint/src/configs/recommended-type-checked.ts index 5dc2fe5a124..2d954c70581 100644 --- a/packages/typescript-eslint/src/configs/recommended-type-checked.ts +++ b/packages/typescript-eslint/src/configs/recommended-type-checked.ts @@ -27,6 +27,7 @@ export default ( '@typescript-eslint/no-base-to-string': 'error', '@typescript-eslint/no-duplicate-enum-values': 'error', '@typescript-eslint/no-duplicate-type-constituents': '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-floating-promises': 'error', diff --git a/packages/typescript-eslint/src/configs/recommended.ts b/packages/typescript-eslint/src/configs/recommended.ts index d3aefe29618..7df78599ea9 100644 --- a/packages/typescript-eslint/src/configs/recommended.ts +++ b/packages/typescript-eslint/src/configs/recommended.ts @@ -24,6 +24,7 @@ export default ( 'no-array-constructor': 'off', '@typescript-eslint/no-array-constructor': 'error', '@typescript-eslint/no-duplicate-enum-values': '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-misused-new': 'error', diff --git a/packages/typescript-eslint/src/configs/strict-type-checked.ts b/packages/typescript-eslint/src/configs/strict-type-checked.ts index 0f786b3f401..1542bf52850 100644 --- a/packages/typescript-eslint/src/configs/strict-type-checked.ts +++ b/packages/typescript-eslint/src/configs/strict-type-checked.ts @@ -33,6 +33,7 @@ export default ( '@typescript-eslint/no-duplicate-enum-values': 'error', '@typescript-eslint/no-duplicate-type-constituents': 'error', '@typescript-eslint/no-dynamic-delete': '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', diff --git a/packages/typescript-eslint/src/configs/strict.ts b/packages/typescript-eslint/src/configs/strict.ts index 0406dc76e0f..680813d7d64 100644 --- a/packages/typescript-eslint/src/configs/strict.ts +++ b/packages/typescript-eslint/src/configs/strict.ts @@ -28,6 +28,7 @@ export default ( '@typescript-eslint/no-array-constructor': 'error', '@typescript-eslint/no-duplicate-enum-values': 'error', '@typescript-eslint/no-dynamic-delete': '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', From 173251c2f585d7c6a4eea25f8fb6b9fcc94f17f8 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 23 Apr 2024 17:10:39 -0400 Subject: [PATCH 02/21] Mention no-props --- packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index ffb2cb76324..915a7cfe683 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -20,6 +20,7 @@ Often, developers writing `{}` actually mean either: - `object`: representing any _object_ value - `unknown`: representing any value _other than `null` and `undefined`_ +- An object with no properties: which can't easily be represented in TypeScript's structural type system To avoid confusion around the `{}` type allowing non-object values, this rule bans usage of the `{}` type. From b83cb509023b2f27b5a114d1de56812ee499cff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 23 Apr 2024 17:48:44 -0400 Subject: [PATCH 03/21] Update packages/eslint-plugin/docs/rules/no-empty-object-type.mdx Co-authored-by: Kirk Waiblinger <53019676+kirkwaiblinger@users.noreply.github.com> --- packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 915a7cfe683..a542ac76d1c 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -19,7 +19,7 @@ let anyNonNullishValue: {} = 'Intentionally allowed by TypeScript.'; Often, developers writing `{}` actually mean either: - `object`: representing any _object_ value -- `unknown`: representing any value _other than `null` and `undefined`_ +- `NonNullable`: representing any value _other than `null` and `undefined`_ - An object with no properties: which can't easily be represented in TypeScript's structural type system To avoid confusion around the `{}` type allowing non-object values, this rule bans usage of the `{}` type. From 39870f1902ee8acaebb44a281637bb385176af6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Tue, 23 Apr 2024 17:53:55 -0400 Subject: [PATCH 04/21] Update packages/eslint-plugin/docs/rules/no-empty-object-type.mdx Co-authored-by: Kirk Waiblinger <53019676+kirkwaiblinger@users.noreply.github.com> --- packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index a542ac76d1c..120439df444 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -19,7 +19,8 @@ let anyNonNullishValue: {} = 'Intentionally allowed by TypeScript.'; Often, developers writing `{}` actually mean either: - `object`: representing any _object_ value -- `NonNullable`: representing any value _other than `null` and `undefined`_ +- `NonNullable`: representing any value _other than `null` and `undefined`_. In most contexts this is the same as `{}`, but more explicit +- `unknown`: representing any value at all, including `null` and `undefined` - An object with no properties: which can't easily be represented in TypeScript's structural type system To avoid confusion around the `{}` type allowing non-object values, this rule bans usage of the `{}` type. From f90ab3f41c332af2c52f1398c4de702110b1b529 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Wed, 24 Apr 2024 08:39:14 -0400 Subject: [PATCH 05/21] Allow in intersections, and touch up docs per suggestions --- .../docs/rules/no-empty-object-type.mdx | 13 ++++++-- .../src/rules/no-empty-object-type.ts | 10 ++++++- .../tests/rules/no-empty-object-type.test.ts | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 120439df444..7e7e6db1c12 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -19,16 +19,23 @@ let anyNonNullishValue: {} = 'Intentionally allowed by TypeScript.'; Often, developers writing `{}` actually mean either: - `object`: representing any _object_ value -- `NonNullable`: representing any value _other than `null` and `undefined`_. In most contexts this is the same as `{}`, but more explicit - `unknown`: representing any value at all, including `null` and `undefined` - An object with no properties: which can't easily be represented in TypeScript's structural type system -To avoid confusion around the `{}` type allowing non-object values, this rule bans usage of the `{}` type. +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. :::tip -If you do have a use case for an API allowing any _non-nullish value_, you can always 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). +If you do have a use case for an API allowing `{}`, you can always 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: + +- `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & unknown`), as this is useful in type system operations. +- `{}` as an empty function body with a `void` return type (e.g. `() => {}`), as this is a function body, not a type. + ## Examples diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index d4d784477b9..b6c70355fbc 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -1,4 +1,5 @@ import type { TSESLint } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { createRule } from '../util'; @@ -26,7 +27,14 @@ export default createRule({ defaultOptions: [], create(context) { return { - 'TSTypeLiteral[members.length=0]'(node): void { + TSTypeLiteral(node): void { + if ( + node.members.length || + node.parent.type === AST_NODE_TYPES.TSIntersectionType + ) { + return; + } + context.report({ messageId: 'banEmptyObjectType', node, diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index fef527edaba..cd2e31eadea 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -11,6 +11,7 @@ ruleTester.run('no-empty-object-type', rule, { 'let value: object;', 'let value: Object;', 'let value: { inner: true };', + 'type MyNonNullable = T & {};', ], invalid: [ { @@ -81,5 +82,34 @@ let value: Record; }, ], }, + { + code: 'type MyUnion = T | {};', + errors: [ + { + column: 23, + line: 1, + endColumn: 25, + endLine: 1, + messageId: 'banEmptyObjectType', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'type MyUnion = T | object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'type MyUnion = T | unknown;', + }, + { + data: { replacement: 'Record' }, + messageId: 'replaceEmptyObjectType', + output: 'type MyUnion = T | Record;', + }, + ], + }, + ], + }, ], }); From 0a5c2bd2c3bd75803e84f5babbdfbdc9f690a871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Wed, 24 Apr 2024 16:03:31 -0400 Subject: [PATCH 06/21] Update packages/eslint-plugin/docs/rules/no-empty-object-type.mdx Co-authored-by: Joshua Chen --- packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 7e7e6db1c12..b5d91fe0a11 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -33,7 +33,7 @@ If you do have a use case for an API allowing `{}`, you can always use an [ESLin Note that this rule does not report: -- `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & unknown`), as this is useful in type system operations. +- `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & {}`), as this is useful in type system operations. - `{}` as an empty function body with a `void` return type (e.g. `() => {}`), as this is a function body, not a type. ## Examples From c01f10e15eaa4bdc9b91a2f50d4e1b1c92eba8f6 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 25 Apr 2024 15:44:19 -0400 Subject: [PATCH 07/21] Trimming --- packages/eslint-plugin/docs/rules/ban-types.mdx | 4 ++++ packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 5 +---- packages/eslint-plugin/src/rules/no-empty-object-type.ts | 1 - 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/ban-types.mdx b/packages/eslint-plugin/docs/rules/ban-types.mdx index 98ce57edd71..ff547f7245f 100644 --- a/packages/eslint-plugin/docs/rules/ban-types.mdx +++ b/packages/eslint-plugin/docs/rules/ban-types.mdx @@ -127,3 +127,7 @@ Example configuration: If your project is a rare one that intentionally deals with the class equivalents of primitives, it might not be worthwhile to enable the default `ban-types` options. You might consider using [ESLint disable comments](https://eslint.org/docs/latest/use/configure/rules#using-configuration-comments-1) for those specific situations instead of completely disabling this rule. + +## Related To + +- [`no-empty-object-type`](./no-empty-object-type.mdx) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index b5d91fe0a11..ab5f523c7ab 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -31,10 +31,7 @@ To avoid confusion around the `{}` type allowing any _non-nullish value_, this r If you do have a use case for an API allowing `{}`, you can always 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: - -- `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & {}`), as this is useful in type system operations. -- `{}` as an empty function body with a `void` return type (e.g. `() => {}`), as this is a function body, not a type. +Note that this rule does not report on `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & {}`), as this is useful in type system operations. ## Examples diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index b6c70355fbc..8879fe0e659 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -18,7 +18,6 @@ export default createRule({ "- If that's what you want, disable this lint rule with an inline comment or in your ESLint config.", '- 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.', - '- If you want a type meaning "empty object", you probably want `Record` instead.', ].join('\n'), replaceEmptyObjectType: 'Replace `{}` with `{{replacement}}`.', }, From f021d26e9d701b26026258f4a587d4f485b68c60 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 25 Apr 2024 16:21:16 -0400 Subject: [PATCH 08/21] Update snapshots --- .../docs-eslint-output-snapshots/no-empty-object-type.shot | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot index 1b1eadc79d0..e5ebbc3cd2e 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot @@ -8,19 +8,16 @@ let anyObject: {}; - If that's what you want, disable this lint rule with an inline comment or in your ESLint config. - 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. - - If you want a type meaning "empty object", you probably want \`Record\` instead. let anyValue: {}; ~~ The \`{}\` ("empty object") type 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 in your ESLint config. - 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. - - If you want a type meaning "empty object", you probably want \`Record\` instead. let emptyObject: {}; ~~ The \`{}\` ("empty object") type 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 in your ESLint config. - 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. - - If you want a type meaning "empty object", you probably want \`Record\` instead. " `; From ca7fb0fe6a0e0654856bb3b25029adafaa405c03 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Fri, 26 Apr 2024 07:29:34 -0400 Subject: [PATCH 09/21] Finish removing Record --- .../docs/rules/no-empty-object-type.mdx | 3 --- .../src/rules/no-empty-object-type.ts | 14 ++++++-------- .../tests/rules/no-empty-object-type.test.ts | 17 ----------------- 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index ab5f523c7ab..125b913130f 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -20,7 +20,6 @@ Often, developers writing `{}` actually mean either: - `object`: representing any _object_ value - `unknown`: representing any value at all, including `null` and `undefined` -- An object with no properties: which can't easily be represented in TypeScript's structural type system 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`. @@ -41,7 +40,6 @@ Note that this rule does not report on `{}` as a type constituent in an intersec ```ts let anyObject: {}; let anyValue: {}; -let emptyObject: {}; ``` @@ -50,7 +48,6 @@ let emptyObject: {}; ```ts let anyObject: object; let anyValue: unknown; -let emptyObject: Record; ``` diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index 8879fe0e659..c68c2e3d21e 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -37,14 +37,12 @@ export default createRule({ context.report({ messageId: 'banEmptyObjectType', node, - suggest: ['object', 'unknown', 'Record'].map( - replacement => ({ - data: { replacement }, - messageId: 'replaceEmptyObjectType', - fix: (fixer): TSESLint.RuleFix => - fixer.replaceText(node, replacement), - }), - ), + suggest: ['object', 'unknown'].map(replacement => ({ + data: { replacement }, + messageId: 'replaceEmptyObjectType', + fix: (fixer): TSESLint.RuleFix => + fixer.replaceText(node, replacement), + })), }); }, }; diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index cd2e31eadea..5c9aa877e9f 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -34,11 +34,6 @@ ruleTester.run('no-empty-object-type', rule, { messageId: 'replaceEmptyObjectType', output: 'let value: unknown;', }, - { - data: { replacement: 'Record' }, - messageId: 'replaceEmptyObjectType', - output: 'let value: Record;', - }, ], }, ], @@ -69,13 +64,6 @@ let value: object; messageId: 'replaceEmptyObjectType', output: ` let value: unknown; - `, - }, - { - data: { replacement: 'Record' }, - messageId: 'replaceEmptyObjectType', - output: ` -let value: Record; `, }, ], @@ -102,11 +90,6 @@ let value: Record; messageId: 'replaceEmptyObjectType', output: 'type MyUnion = T | unknown;', }, - { - data: { replacement: 'Record' }, - messageId: 'replaceEmptyObjectType', - output: 'type MyUnion = T | Record;', - }, ], }, ], From fad7a5e42eca9b3fcff91d9d9e57f0bf1bdcf56b Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Fri, 26 Apr 2024 07:33:13 -0400 Subject: [PATCH 10/21] Correct phrasing on Object --- packages/eslint-plugin/docs/rules/ban-types.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/ban-types.mdx b/packages/eslint-plugin/docs/rules/ban-types.mdx index ff547f7245f..b52ae22df7d 100644 --- a/packages/eslint-plugin/docs/rules/ban-types.mdx +++ b/packages/eslint-plugin/docs/rules/ban-types.mdx @@ -64,7 +64,7 @@ const capitalObj: { a: string } = { a: 'string' }; The default options provide a set of "best practices", intended to provide safety and standardization in your codebase: -- Don't use the upper-case primitive types, you should use the lower-case types for consistency. +- Don't use the upper-case primitive types or `Object`, you should use the lower-case types for consistency. - Avoid the `Function` type, as it provides little safety for the following reasons: - It provides no type safety when calling the value, which means it's easy to provide the wrong arguments. - It accepts class declarations, which will fail when called, as they are called without the `new` keyword. From e8a2d7fb5d59be94e7febf24d2b78facf675a8f3 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 30 Apr 2024 02:01:20 -0400 Subject: [PATCH 11/21] Merge with no-empty-interface --- .../docs/rules/no-empty-interface.mdx | 6 + .../docs/rules/no-empty-object-type.mdx | 65 ++- packages/eslint-plugin/src/configs/all.ts | 1 - .../src/configs/stylistic-type-checked.ts | 1 - .../eslint-plugin/src/configs/stylistic.ts | 1 - .../src/rules/no-empty-interface.ts | 2 +- .../src/rules/no-empty-object-type.ts | 166 +++++-- .../no-empty-object-type.shot | 51 ++- .../tests/rules/no-empty-object-type.test.ts | 411 +++++++++++++++++- .../no-empty-object-type.shot | 26 +- packages/typescript-eslint/src/configs/all.ts | 1 - .../src/configs/stylistic-type-checked.ts | 1 - .../src/configs/stylistic.ts | 1 - 13 files changed, 685 insertions(+), 48 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-interface.mdx b/packages/eslint-plugin/docs/rules/no-empty-interface.mdx index aec49748124..733d8b39ebe 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-interface.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-interface.mdx @@ -9,6 +9,12 @@ import TabItem from '@theme/TabItem'; > > See **https://typescript-eslint.io/rules/no-empty-interface** for documentation. +:::danger Deprecated + +This rule has been deprecated in favour of the more comprehensive [`@typescript-eslint/no-empty-object-type`](./no-empty-object-type.mdx) rule. + +::: + An empty interface in TypeScript does very little: any non-nullable value is assignable to `{}`. Using an empty interface is often a sign of programmer error, such as misunderstanding the concept of `{}` or forgetting to fill in fields. diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 125b913130f..bf1852a0b75 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -25,12 +25,16 @@ In other words, the "empty object" type `{}` really means _"any value that is de 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 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. `type NonNullable = T & {}`), as this is useful in type system operations. +Note that this rule does not report on: + +- `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & {}`), as this can be useful in type system operations. +- Interfaces that extend from multiple other interfaces. ## Examples @@ -40,6 +44,12 @@ Note that this rule does not report on `{}` as a type constituent in an intersec ```ts let anyObject: {}; let anyValue: {}; + +interface AnyObjectA {} +interface AnyValueA {} + +type AnyObjectB = {}; +type AnyValueB = {}; ``` @@ -48,11 +58,60 @@ let anyValue: {}; ```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 }; ``` +## Options + +By default, this rule flags both interfaces and object types. + +:::warning +We strongly recommend not using either option's `'always'`. +The "empty object" type is a common source of confusion for even experienced TypeScript developers. +Consider using `object` or `unknown` as a type instead. +::: + +### `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 **correct** code for this rule with `{ allowInterfaces: 'with-single-extends' }`: + +```ts option='{ "allowInterfaces": "with-single-extends" }' showPlaygroundButton +interface Base { + value: boolean; +} + +interface Derived extends Base {} +``` + +### `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 + ## 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. @@ -62,7 +121,3 @@ Projects that extensively use type operations such as conditional types and mapp - [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) - -## Related To - -- [`no-empty-interface`](./no-empty-interface.mdx) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index a9eb685a9c3..0ac9c3653d9 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -54,7 +54,6 @@ export = { '@typescript-eslint/no-dynamic-delete': 'error', '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', diff --git a/packages/eslint-plugin/src/configs/stylistic-type-checked.ts b/packages/eslint-plugin/src/configs/stylistic-type-checked.ts index 0bb075e5c8f..3766c7f5695 100644 --- a/packages/eslint-plugin/src/configs/stylistic-type-checked.ts +++ b/packages/eslint-plugin/src/configs/stylistic-type-checked.ts @@ -23,7 +23,6 @@ export = { '@typescript-eslint/no-confusing-non-null-assertion': 'error', 'no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'error', - '@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-inferrable-types': 'error', '@typescript-eslint/non-nullable-type-assertion-style': 'error', '@typescript-eslint/prefer-for-of': 'error', diff --git a/packages/eslint-plugin/src/configs/stylistic.ts b/packages/eslint-plugin/src/configs/stylistic.ts index 74f2586dd78..d9ac6faf9d9 100644 --- a/packages/eslint-plugin/src/configs/stylistic.ts +++ b/packages/eslint-plugin/src/configs/stylistic.ts @@ -21,7 +21,6 @@ export = { '@typescript-eslint/no-confusing-non-null-assertion': 'error', 'no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'error', - '@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-inferrable-types': 'error', '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', diff --git a/packages/eslint-plugin/src/rules/no-empty-interface.ts b/packages/eslint-plugin/src/rules/no-empty-interface.ts index 674b86d44bf..b3ada33093f 100644 --- a/packages/eslint-plugin/src/rules/no-empty-interface.ts +++ b/packages/eslint-plugin/src/rules/no-empty-interface.ts @@ -17,8 +17,8 @@ export default createRule({ type: 'suggestion', docs: { description: 'Disallow the declaration of empty interfaces', - recommended: 'stylistic', }, + deprecated: true, fixable: 'code', hasSuggestions: true, messages: { diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index c68c2e3d21e..15b9ac33ada 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -3,7 +3,25 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { createRule } from '../util'; -export default createRule({ +export type AllowInterfaces = 'always' | 'never' | 'with-single-extends'; + +export type AllowObjectTypes = 'always' | 'never'; + +export type Options = [ + { + allowInterfaces?: AllowInterfaces; + allowObjectTypes?: AllowObjectTypes; + }, +]; + +export type MessageIds = + | 'noEmpty' + | 'noEmptyInterfaceWithSuper' + | 'replaceEmptyInterface' + | 'replaceEmptyInterfaceWithSuper' + | 'replaceEmptyObjectType'; + +export default createRule({ name: 'no-empty-object-type', meta: { type: 'suggestion', @@ -13,38 +31,136 @@ export default createRule({ }, hasSuggestions: true, messages: { - banEmptyObjectType: [ + noEmpty: [ 'The `{}` ("empty object") type 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 in your ESLint config.", + "- 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'), + 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: [], + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + allowInterfaces: { + enum: ['always', 'never', 'with-single-extends'], + type: 'string', + }, + allowObjectTypes: { + enum: ['always', 'never'], + type: 'string', + }, + }, + }, + ], }, - defaultOptions: [], - create(context) { + defaultOptions: [ + { + allowInterfaces: 'never', + allowObjectTypes: 'never', + }, + ], + create(context, [{ allowInterfaces, allowObjectTypes }]) { return { - TSTypeLiteral(node): void { - if ( - node.members.length || - node.parent.type === AST_NODE_TYPES.TSIntersectionType - ) { - return; - } - - context.report({ - messageId: 'banEmptyObjectType', - node, - suggest: ['object', 'unknown'].map(replacement => ({ - data: { replacement }, - messageId: 'replaceEmptyObjectType', - fix: (fixer): TSESLint.RuleFix => - fixer.replaceText(node, replacement), - })), - }); - }, + ...(allowInterfaces !== 'always' && { + TSInterfaceDeclaration(node): void { + 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: 'noEmpty', + ...(!mergedWithClassDeclaration && { + suggest: ['object', 'unknown'].map(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 + ) { + return; + } + + context.report({ + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', + node, + suggest: ['object', 'unknown'].map(replacement => ({ + data: { replacement }, + messageId: 'replaceEmptyObjectType', + fix: (fixer): TSESLint.RuleFix => + fixer.replaceText(node, replacement), + })), + }); + }, + }), }; }, }); diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot index e5ebbc3cd2e..4dba1169055 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot @@ -5,17 +5,34 @@ exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint outp let anyObject: {}; ~~ The \`{}\` ("empty object") type 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 in your ESLint config. + - If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' 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. let anyValue: {}; ~~ The \`{}\` ("empty object") type 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 in your ESLint config. + - If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' 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. -let emptyObject: {}; + +interface AnyObjectA {} + ~~~~~~~~~~ The \`{}\` ("empty object") type 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 'allowInterfaces' 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. +interface AnyValueA {} + ~~~~~~~~~ The \`{}\` ("empty object") type 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 'allowInterfaces' 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. + +type AnyObjectB = {}; + ~~ The \`{}\` ("empty object") type 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 'allowObjectTypes' 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. +type AnyValueB = {}; ~~ The \`{}\` ("empty object") type 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 in your ESLint config. + - If that's what you want, disable this lint rule with an inline comment or configure the 'allowObjectTypes' 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. " @@ -26,6 +43,30 @@ exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint outp let anyObject: object; let anyValue: unknown; -let emptyObject: Record; + +type AnyObjectA = object; +type AnyValueA = unknown; + +type AnyObjectB = object; +type AnyValueB = unknown; + +let objectWith: { property: boolean }; + +interface InterfaceWith { + property: boolean; +} + +type TypeWith = { property: boolean }; +" +`; + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 3`] = ` +"Options: { "allowInterfaces": "with-single-extends" } + +interface Base { + value: boolean; +} + +interface Derived extends Base {} " `; diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index 5c9aa877e9f..cd4717959c6 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -8,12 +8,413 @@ const ruleTester = new RuleTester({ ruleTester.run('no-empty-object-type', rule, { valid: [ + ` +interface Base { + name: string; +} + `, + ` +interface Base { + name: string; +} + +interface Derived { + age: number; +} + +// valid because extending multiple interfaces can be used instead of a union type +interface Both extends Base, Derived {} + `, + { + code: 'interface Base {}', + options: [{ allowInterfaces: 'always' }], + }, + { + code: ` +interface Base { + name: string; +} + +interface Derived extends Base {} + `, + options: [{ allowInterfaces: 'with-single-extends' }], + }, + { + code: ` +interface Base { + props: string; +} + +interface Derived extends Base {} + +class Derived {} + `, + options: [{ allowInterfaces: 'with-single-extends' }], + }, 'let value: object;', 'let value: Object;', 'let value: { inner: true };', 'type MyNonNullable = T & {};', + { + code: 'type Base = {};', + options: [{ allowObjectTypes: 'always' }], + }, ], invalid: [ + { + code: 'interface Base {}', + errors: [ + { + data: { option: 'allowInterfaces' }, + messageId: 'noEmpty', + line: 1, + column: 11, + }, + ], + }, + { + code: 'interface Base {}', + errors: [ + { + data: { option: 'allowInterfaces' }, + messageId: 'noEmpty', + line: 1, + column: 11, + }, + ], + options: [{ allowInterfaces: 'never' }], + }, + { + code: ` +interface Base { + props: string; +} + +interface Derived extends Base {} + +class Other {} + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 6, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: ` +interface Base { + props: string; +} + +type Derived = Base + +class Other {} + `, + }, + ], + }, + ], + }, + { + code: ` +interface Base { + props: string; +} + +interface Derived extends Base {} + +class Derived {} + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 6, + column: 11, + }, + ], + }, + { + code: ` +interface Base { + props: string; +} + +interface Derived extends Base {} + +const derived = class Derived {}; + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 6, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: ` +interface Base { + props: string; +} + +type Derived = Base + +const derived = class Derived {}; + `, + }, + ], + }, + ], + }, + { + code: ` +interface Base { + name: string; +} + +interface Derived extends Base {} + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 6, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: ` +interface Base { + name: string; +} + +type Derived = Base + `, + }, + ], + }, + ], + }, + { + code: 'interface Base extends Array {}', + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 1, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: `type Base = Array`, + }, + ], + }, + ], + }, + { + code: 'interface Base extends Array {}', + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 1, + column: 11, + endColumn: 15, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: `type Base = Array`, + }, + ], + }, + { + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', + line: 1, + column: 39, + endColumn: 41, + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: `interface Base extends Array {}`, + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: `interface Base extends Array {}`, + }, + ], + }, + ], + }, + { + code: ` +interface Derived { + property: string; +} +interface Base extends Array {} + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 5, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: ` +interface Derived { + property: string; +} +type Base = Array + `, + }, + ], + }, + ], + }, + { + code: ` +type R = Record; +interface Base extends R {} + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 3, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: ` +type R = Record; +type Base = R + `, + }, + ], + }, + ], + }, + { + code: 'interface Base extends Derived {}', + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 1, + column: 11, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: `type Base = Derived`, + }, + ], + }, + ], + }, + { + filename: 'test.d.ts', + code: ` +declare namespace BaseAndDerived { + type Base = typeof base; + export interface Derived extends Base {} +} + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 4, + column: 20, + endLine: 4, + endColumn: 27, + suggestions: [ + { + messageId: 'replaceEmptyInterfaceWithSuper', + output: ` +declare namespace BaseAndDerived { + type Base = typeof base; + export type Derived = Base +} + `, + }, + ], + }, + ], + }, + { + code: 'type Base = {};', + errors: [ + { + column: 13, + line: 1, + endColumn: 15, + endLine: 1, + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'type Base = object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'type Base = unknown;', + }, + ], + }, + ], + }, + { + code: 'type Base = {};', + errors: [ + { + column: 13, + line: 1, + endColumn: 15, + endLine: 1, + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'type Base = object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'type Base = unknown;', + }, + ], + }, + ], + options: [{ allowObjectTypes: 'never' }], + }, + { + code: 'let value: {};', + errors: [ + { + column: 12, + line: 1, + endColumn: 14, + endLine: 1, + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: unknown;', + }, + ], + }, + ], + }, { code: 'let value: {};', errors: [ @@ -22,7 +423,8 @@ ruleTester.run('no-empty-object-type', rule, { line: 1, endColumn: 14, endLine: 1, - messageId: 'banEmptyObjectType', + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', suggestions: [ { data: { replacement: 'object' }, @@ -37,6 +439,7 @@ ruleTester.run('no-empty-object-type', rule, { ], }, ], + options: [{ allowObjectTypes: 'never' }], }, { code: ` @@ -50,7 +453,8 @@ let value: { endLine: 4, column: 12, endColumn: 2, - messageId: 'banEmptyObjectType', + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', suggestions: [ { data: { replacement: 'object' }, @@ -78,7 +482,8 @@ let value: unknown; line: 1, endColumn: 25, endLine: 1, - messageId: 'banEmptyObjectType', + data: { option: 'allowObjectTypes' }, + messageId: 'noEmpty', suggestions: [ { data: { replacement: 'object' }, diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot index 8f658c6d922..2542efd5441 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot @@ -4,11 +4,31 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos " # SCHEMA: -[] +[ + { + "additionalProperties": false, + "properties": { + "allowInterfaces": { + "enum": ["always", "never", "with-single-extends"], + "type": "string" + }, + "allowObjectTypes": { + "enum": ["always", "never"], + "type": "string" + } + }, + "type": "object" + } +] # TYPES: -/** No options declared */ -type Options = [];" +type Options = [ + { + allowInterfaces?: 'always' | 'never' | 'with-single-extends'; + allowObjectTypes?: 'always' | 'never'; + }, +]; +" `; diff --git a/packages/typescript-eslint/src/configs/all.ts b/packages/typescript-eslint/src/configs/all.ts index 5341ff26fd2..01d4a56f9ee 100644 --- a/packages/typescript-eslint/src/configs/all.ts +++ b/packages/typescript-eslint/src/configs/all.ts @@ -63,7 +63,6 @@ export default ( '@typescript-eslint/no-dynamic-delete': 'error', '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', diff --git a/packages/typescript-eslint/src/configs/stylistic-type-checked.ts b/packages/typescript-eslint/src/configs/stylistic-type-checked.ts index 95b8c516fae..63ef5a71d0f 100644 --- a/packages/typescript-eslint/src/configs/stylistic-type-checked.ts +++ b/packages/typescript-eslint/src/configs/stylistic-type-checked.ts @@ -32,7 +32,6 @@ export default ( '@typescript-eslint/no-confusing-non-null-assertion': 'error', 'no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'error', - '@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-inferrable-types': 'error', '@typescript-eslint/non-nullable-type-assertion-style': 'error', '@typescript-eslint/prefer-for-of': 'error', diff --git a/packages/typescript-eslint/src/configs/stylistic.ts b/packages/typescript-eslint/src/configs/stylistic.ts index 6a9b4cd306d..6e12fe9de23 100644 --- a/packages/typescript-eslint/src/configs/stylistic.ts +++ b/packages/typescript-eslint/src/configs/stylistic.ts @@ -30,7 +30,6 @@ export default ( '@typescript-eslint/no-confusing-non-null-assertion': 'error', 'no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'error', - '@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-inferrable-types': 'error', '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', From 3971d71103016652f7bba8dc00192259ced75980 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Tue, 30 Apr 2024 02:05:45 -0400 Subject: [PATCH 12/21] nit the tip --- packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index bf1852a0b75..dcb5dfd5fe3 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -28,7 +28,7 @@ To avoid confusion around the `{}` type allowing any _non-nullish value_, this r 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 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). +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: From d33a2460eb3a1932f645a8dce634d659118b0cf7 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Wed, 8 May 2024 08:36:34 -0700 Subject: [PATCH 13/21] Explicit report message for interfaces --- .../src/rules/no-empty-object-type.ts | 23 +++++++++++-------- .../tests/rules/no-empty-object-type.test.ts | 18 +++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index 15b9ac33ada..456fd0ac9b7 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -15,12 +15,21 @@ export type Options = [ ]; export type MessageIds = - | 'noEmpty' + | '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({ name: 'no-empty-object-type', meta: { @@ -31,12 +40,8 @@ export default createRule({ }, hasSuggestions: true, messages: { - noEmpty: [ - 'The `{}` ("empty object") type 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'), + 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}}`.', @@ -93,7 +98,7 @@ export default createRule({ context.report({ data: { option: 'allowInterfaces' }, node: node.id, - messageId: 'noEmpty', + messageId: 'noEmptyInterface', ...(!mergedWithClassDeclaration && { suggest: ['object', 'unknown'].map(replacement => ({ fix(fixer): TSESLint.RuleFix { @@ -150,7 +155,7 @@ export default createRule({ context.report({ data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', node, suggest: ['object', 'unknown'].map(replacement => ({ data: { replacement }, diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index cd4717959c6..68509ecdae2 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -66,7 +66,7 @@ class Derived {} errors: [ { data: { option: 'allowInterfaces' }, - messageId: 'noEmpty', + messageId: 'noEmptyInterface', line: 1, column: 11, }, @@ -77,7 +77,7 @@ class Derived {} errors: [ { data: { option: 'allowInterfaces' }, - messageId: 'noEmpty', + messageId: 'noEmptyInterface', line: 1, column: 11, }, @@ -227,7 +227,7 @@ type Derived = Base }, { data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', line: 1, column: 39, endColumn: 41, @@ -348,7 +348,7 @@ declare namespace BaseAndDerived { endColumn: 15, endLine: 1, data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', suggestions: [ { data: { replacement: 'object' }, @@ -373,7 +373,7 @@ declare namespace BaseAndDerived { endColumn: 15, endLine: 1, data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', suggestions: [ { data: { replacement: 'object' }, @@ -399,7 +399,7 @@ declare namespace BaseAndDerived { endColumn: 14, endLine: 1, data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', suggestions: [ { data: { replacement: 'object' }, @@ -424,7 +424,7 @@ declare namespace BaseAndDerived { endColumn: 14, endLine: 1, data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', suggestions: [ { data: { replacement: 'object' }, @@ -454,7 +454,7 @@ let value: { column: 12, endColumn: 2, data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', suggestions: [ { data: { replacement: 'object' }, @@ -483,7 +483,7 @@ let value: unknown; endColumn: 25, endLine: 1, data: { option: 'allowObjectTypes' }, - messageId: 'noEmpty', + messageId: 'noEmptyObject', suggestions: [ { data: { replacement: 'object' }, From 28ed70dca5f1742960ce3bf0dee398029171aa92 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Wed, 8 May 2024 18:59:03 -0700 Subject: [PATCH 14/21] Add in-type-alias-with-name --- .../docs/rules/no-empty-object-type.mdx | 28 +++++++++++++++++-- .../src/rules/no-empty-object-type.ts | 13 +++++++-- .../tests/rules/no-empty-object-type.test.ts | 17 +++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index dcb5dfd5fe3..85611ae64c2 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -82,8 +82,8 @@ type TypeWith = { property: boolean }; By default, this rule flags both interfaces and object types. :::warning -We strongly recommend not using either option's `'always'`. -The "empty object" type is a common source of confusion for even experienced TypeScript developers. +We strongly recommend keeping both option to the default, `'never'`. +The "empty object" type is often confusing even for experienced TypeScript developers. Consider using `object` or `unknown` as a type instead. ::: @@ -110,8 +110,32 @@ interface Derived extends Base {} Whether to allow empty object type literals, as one of: - `'always'`: to always allow object type literals with no fields +- `'in-type-alias-with-name'`: to only allow object type literals as the type in a named object type alias - `'never'` _(default)_: to never allow object type literals with no fields +Example of code for this rule with `{ allowObjectTypes: 'in-type-alias-with-name' }`: + + + + +```ts option='{ "allowObjectTypes": "in-type-alias-with-name" }' showPlaygroundButton +type EmptyObjectProps = {}; + +declare function takesEmptyObject(value: {}): void; +``` + + + + +```ts option='{ "allowObjectTypes": "in-type-alias-with-name" }' showPlaygroundButton +type EmptyObjectProps = {} | null; + +declare function takesEmptyObject(value: object): void; +``` + + + + ## 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. diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index 456fd0ac9b7..07f141a632c 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -11,6 +11,7 @@ export type Options = [ { allowInterfaces?: AllowInterfaces; allowObjectTypes?: AllowObjectTypes; + allowInTypeAliasWithName?: boolean; }, ]; @@ -59,7 +60,7 @@ export default createRule({ type: 'string', }, allowObjectTypes: { - enum: ['always', 'never'], + enum: ['always', 'in-type-alias-with-name', 'never'], type: 'string', }, }, @@ -70,9 +71,13 @@ export default createRule({ { allowInterfaces: 'never', allowObjectTypes: 'never', + allowInTypeAliasWithName: false, }, ], - create(context, [{ allowInterfaces, allowObjectTypes }]) { + create( + context, + [{ allowInterfaces, allowInTypeAliasWithName, allowObjectTypes }], + ) { return { ...(allowInterfaces !== 'always' && { TSInterfaceDeclaration(node): void { @@ -148,7 +153,9 @@ export default createRule({ TSTypeLiteral(node): void { if ( node.members.length || - node.parent.type === AST_NODE_TYPES.TSIntersectionType + node.parent.type === AST_NODE_TYPES.TSIntersectionType || + (allowInTypeAliasWithName && + node.parent.type === AST_NODE_TYPES.TSTypeAliasDeclaration) ) { return; } diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index 68509ecdae2..414f6f0d5d0 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -59,6 +59,10 @@ class Derived {} code: 'type Base = {};', options: [{ allowObjectTypes: 'always' }], }, + { + code: 'type Base = {};', + options: [{ allowInTypeAliasWithName: true }], + }, ], invalid: [ { @@ -499,5 +503,18 @@ let value: unknown; }, ], }, + { + code: 'type Base = {} | null;', + errors: [ + { + column: 13, + line: 1, + endColumn: 15, + endLine: 1, + messageId: 'noEmptyObject', + }, + ], + options: [{ allowInTypeAliasWithName: true }], + }, ], }); From 9b6dd1a80bbceadecfe54733bf89058e8a34b92a Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 9 May 2024 08:02:04 -0700 Subject: [PATCH 15/21] Switched to more general allowWithName --- .../eslint-plugin/TSLINT_RULE_ALTERNATIVES.md | 4 +- .../docs/rules/no-empty-object-type.mdx | 22 ++++++---- .../src/rules/no-empty-object-type.ts | 24 +++++++---- .../tests/rules/no-empty-object-type.test.ts | 42 ++++++++++++++++++- packages/typescript-estree/src/parser.ts | 2 +- .../src/ts-estree/ts-nodes.ts | 4 +- packages/utils/src/json-schema.ts | 2 +- packages/utils/src/ts-eslint/Rule.ts | 2 +- 8 files changed, 76 insertions(+), 26 deletions(-) diff --git a/packages/eslint-plugin/TSLINT_RULE_ALTERNATIVES.md b/packages/eslint-plugin/TSLINT_RULE_ALTERNATIVES.md index 8276fabd922..d6dd55f8b9e 100644 --- a/packages/eslint-plugin/TSLINT_RULE_ALTERNATIVES.md +++ b/packages/eslint-plugin/TSLINT_RULE_ALTERNATIVES.md @@ -24,7 +24,7 @@ It lists all TSLint rules along side rules from the ESLint ecosystem that are th | [`member-access`] | ✅ | [`@typescript-eslint/explicit-member-accessibility`] | | [`member-ordering`] | ✅ | [`@typescript-eslint/member-ordering`] | | [`no-any`] | ✅ | [`@typescript-eslint/no-explicit-any`] | -| [`no-empty-interface`] | ✅ | [`@typescript-eslint/no-empty-interface`] | +| [`no-empty-interface`] | ✅ | [`@typescript-eslint/no-empty-object-type`] | | [`no-import-side-effect`] | 🔌 | [`import/no-unassigned-import`] | | [`no-inferrable-types`] | ✅ | [`@typescript-eslint/no-inferrable-types`] | | [`no-internal-module`] | ✅ | [`@typescript-eslint/prefer-namespace-keyword`] | @@ -604,7 +604,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- [`@typescript-eslint/member-ordering`]: https://typescript-eslint.io/rules/member-ordering [`@typescript-eslint/method-signature-style`]: https://typescript-eslint.io/rules/method-signature-style [`@typescript-eslint/no-explicit-any`]: https://typescript-eslint.io/rules/no-explicit-any -[`@typescript-eslint/no-empty-interface`]: https://typescript-eslint.io/rules/no-empty-interface +[`@typescript-eslint/no-empty-object-type`]: https://typescript-eslint.io/rules/no-empty-object-type [`@typescript-eslint/no-implied-eval`]: https://typescript-eslint.io/rules/no-implied-eval [`@typescript-eslint/no-inferrable-types`]: https://typescript-eslint.io/rules/no-inferrable-types [`@typescript-eslint/prefer-namespace-keyword`]: https://typescript-eslint.io/rules/prefer-namespace-keyword diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 85611ae64c2..37e53aba56e 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -82,7 +82,7 @@ type TypeWith = { property: boolean }; By default, this rule flags both interfaces and object types. :::warning -We strongly recommend keeping both option to the default, `'never'`. +We recommend keeping all options to the default and not allowing exceptions to this rule. The "empty object" type is often confusing even for experienced TypeScript developers. Consider using `object` or `unknown` as a type instead. ::: @@ -110,27 +110,31 @@ interface Derived extends Base {} Whether to allow empty object type literals, as one of: - `'always'`: to always allow object type literals with no fields -- `'in-type-alias-with-name'`: to only allow object type literals as the type in a named object type alias - `'never'` _(default)_: to never allow object type literals with no fields -Example of code for this rule with `{ allowObjectTypes: 'in-type-alias-with-name' }`: +### `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 still includes a pattern of declaring empty types with `{}` instead of `object`. + +Examples of code for this rule with `{ allowWithName: 'Props$' }`: -```ts option='{ "allowObjectTypes": "in-type-alias-with-name" }' showPlaygroundButton -type EmptyObjectProps = {}; +```ts option='{ "allowInterfaces": "Props$" }' showPlaygroundButton +interface InterfaceValue {} -declare function takesEmptyObject(value: {}): void; +type TypeValue = {}; ``` -```ts option='{ "allowObjectTypes": "in-type-alias-with-name" }' showPlaygroundButton -type EmptyObjectProps = {} | null; +```ts option='{ "allowInterfaces": "Props$" }' showPlaygroundButton +interface InterfaceProps {} -declare function takesEmptyObject(value: object): void; +type TypeProps = {}; ``` diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index 07f141a632c..47f3eb694c6 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -11,7 +11,7 @@ export type Options = [ { allowInterfaces?: AllowInterfaces; allowObjectTypes?: AllowObjectTypes; - allowInTypeAliasWithName?: boolean; + allowWithName?: string; }, ]; @@ -63,6 +63,9 @@ export default createRule({ enum: ['always', 'in-type-alias-with-name', 'never'], type: 'string', }, + allowWithName: { + type: 'string', + }, }, }, ], @@ -71,16 +74,20 @@ export default createRule({ { allowInterfaces: 'never', allowObjectTypes: 'never', - allowInTypeAliasWithName: false, }, ], - create( - context, - [{ allowInterfaces, allowInTypeAliasWithName, allowObjectTypes }], - ) { + 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 || @@ -154,8 +161,9 @@ export default createRule({ if ( node.members.length || node.parent.type === AST_NODE_TYPES.TSIntersectionType || - (allowInTypeAliasWithName && - node.parent.type === AST_NODE_TYPES.TSTypeAliasDeclaration) + (allowWithNameTester && + node.parent.type === AST_NODE_TYPES.TSTypeAliasDeclaration && + allowWithNameTester.test(node.parent.id.name)) ) { return; } diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index 414f6f0d5d0..f8201b32c4f 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -61,7 +61,19 @@ class Derived {} }, { code: 'type Base = {};', - options: [{ allowInTypeAliasWithName: true }], + options: [{ allowWithName: 'Base' }], + }, + { + code: 'type BaseProps = {};', + options: [{ allowWithName: 'Props$' }], + }, + { + code: 'interface Base {}', + options: [{ allowWithName: 'Base' }], + }, + { + code: 'interface BaseProps {}', + options: [{ allowWithName: 'Props$' }], }, ], invalid: [ @@ -514,7 +526,33 @@ let value: unknown; messageId: 'noEmptyObject', }, ], - options: [{ allowInTypeAliasWithName: true }], + options: [{ allowWithName: 'Base' }], + }, + { + code: 'type Base = {};', + errors: [ + { + column: 13, + line: 1, + endColumn: 15, + endLine: 1, + messageId: 'noEmptyObject', + }, + ], + options: [{ allowWithName: 'Mismatch' }], + }, + { + code: 'interface Base {}', + errors: [ + { + column: 11, + line: 1, + endColumn: 15, + endLine: 1, + messageId: 'noEmptyInterface', + }, + ], + options: [{ allowWithName: '.*Props$' }], }, ], }); diff --git a/packages/typescript-estree/src/parser.ts b/packages/typescript-estree/src/parser.ts index 4c4b7f61a84..59482f03dee 100644 --- a/packages/typescript-estree/src/parser.ts +++ b/packages/typescript-estree/src/parser.ts @@ -81,7 +81,7 @@ function getProgramAndAST( ); } -// eslint-disable-next-line @typescript-eslint/no-empty-interface +// eslint-disable-next-line @typescript-eslint/no-empty-object-type interface EmptyObject {} type AST = TSESTree.Program & (T['comment'] extends true ? { comments: TSESTree.Comment[] } : EmptyObject) & diff --git a/packages/typescript-estree/src/ts-estree/ts-nodes.ts b/packages/typescript-estree/src/ts-estree/ts-nodes.ts index 2a07ee0e7ea..9501c031f6f 100644 --- a/packages/typescript-estree/src/ts-estree/ts-nodes.ts +++ b/packages/typescript-estree/src/ts-estree/ts-nodes.ts @@ -2,7 +2,7 @@ import type * as ts from 'typescript'; // Workaround to support new TS version features for consumers on old TS versions // Eg: https://github.com/typescript-eslint/typescript-eslint/issues/2388, https://github.com/typescript-eslint/typescript-eslint/issues/2784 -/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/no-empty-object-type */ declare module 'typescript' { // added in TS 4.5, deprecated in TS 5.3 export interface AssertClause extends ts.ImportAttributes {} @@ -15,7 +15,7 @@ declare module 'typescript' { export interface ImportAttribute extends ts.Node {} export interface ImportAttributes extends ts.Node {} } -/* eslint-enable @typescript-eslint/no-empty-interface */ +/* eslint-enable @typescript-eslint/no-empty-object-type */ export type TSToken = ts.Token; diff --git a/packages/utils/src/json-schema.ts b/packages/utils/src/json-schema.ts index 73ab2f3ddc8..4f822a32267 100644 --- a/packages/utils/src/json-schema.ts +++ b/packages/utils/src/json-schema.ts @@ -41,7 +41,7 @@ export interface JSONSchema4Object { // Workaround for infinite type recursion // https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540 -// eslint-disable-next-line @typescript-eslint/no-empty-interface +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface JSONSchema4Array extends Array {} /** diff --git a/packages/utils/src/ts-eslint/Rule.ts b/packages/utils/src/ts-eslint/Rule.ts index 838a55217a8..ebbbd8d446e 100644 --- a/packages/utils/src/ts-eslint/Rule.ts +++ b/packages/utils/src/ts-eslint/Rule.ts @@ -579,7 +579,7 @@ type RuleListenerExitSelectors = { }; type RuleListenerCatchAllBaseCase = Record; // Interface to merge into for anyone that wants to add more selectors -// eslint-disable-next-line @typescript-eslint/no-empty-interface +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface RuleListenerExtension { // The code path functions below were introduced in ESLint v8.7.0 but are // intentionally commented out because they cause unresolvable compiler From b7a70b808e17bc5cb691887ab6a348aa4b369242 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 9 May 2024 13:58:09 -0700 Subject: [PATCH 16/21] snapshot -u --- .../tests/schema-snapshots/no-empty-object-type.shot | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot index 2542efd5441..632b3ce83ca 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot @@ -13,7 +13,10 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos "type": "string" }, "allowObjectTypes": { - "enum": ["always", "never"], + "enum": ["always", "in-type-alias-with-name", "never"], + "type": "string" + }, + "allowWithName": { "type": "string" } }, @@ -27,7 +30,8 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos type Options = [ { allowInterfaces?: 'always' | 'never' | 'with-single-extends'; - allowObjectTypes?: 'always' | 'never'; + allowObjectTypes?: 'always' | 'in-type-alias-with-name' | 'never'; + allowWithName?: string; }, ]; " From 713404a703b7c0bcb1732fe23e0989e76688ea2e Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 9 May 2024 14:21:21 -0700 Subject: [PATCH 17/21] snapshot -u --- .../docs/rules/no-empty-object-type.mdx | 4 +-- .../no-empty-object-type.shot | 32 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 37e53aba56e..3dbb93092f5 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -122,7 +122,7 @@ Examples of code for this rule with `{ allowWithName: 'Props$' }`: -```ts option='{ "allowInterfaces": "Props$" }' showPlaygroundButton +```ts option='{ "allowWithName": "Props$" }' showPlaygroundButton interface InterfaceValue {} type TypeValue = {}; @@ -131,7 +131,7 @@ type TypeValue = {}; -```ts option='{ "allowInterfaces": "Props$" }' showPlaygroundButton +```ts option='{ "allowWithName": "Props$" }' showPlaygroundButton interface InterfaceProps {} type TypeProps = {}; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot index 4dba1169055..15ee654ff40 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot @@ -15,12 +15,12 @@ let anyValue: {}; - If you want a type meaning "any value", you probably want \`unknown\` instead. interface AnyObjectA {} - ~~~~~~~~~~ The \`{}\` ("empty object") type allows any non-nullish value, including literals like \`0\` and \`""\`. + ~~~~~~~~~~ An empty interface declaration 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 'allowInterfaces' 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. interface AnyValueA {} - ~~~~~~~~~ The \`{}\` ("empty object") type allows any non-nullish value, including literals like \`0\` and \`""\`. + ~~~~~~~~~ An empty interface declaration 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 'allowInterfaces' 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. @@ -70,3 +70,31 @@ interface Base { interface Derived extends Base {} " `; + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 4`] = ` +"Incorrect +Options: { "allowWithName": "Props$" } + +interface InterfaceValue {} + ~~~~~~~~~~~~~~ An empty interface declaration 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 'allowInterfaces' 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. + +type TypeValue = {}; + ~~ The \`{}\` ("empty object") type 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 'allowObjectTypes' 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. +" +`; + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 5`] = ` +"Correct +Options: { "allowWithName": "Props$" } + +interface InterfaceProps {} + +type TypeProps = {}; +" +`; From 218d8e5fc8d63e0dd4653ac2e6aef408c22aa98a Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Thu, 9 May 2024 14:24:25 -0700 Subject: [PATCH 18/21] Fixed up unit tests --- .../src/rules/no-empty-object-type.ts | 1 + .../tests/rules/no-empty-object-type.test.ts | 49 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-empty-object-type.ts b/packages/eslint-plugin/src/rules/no-empty-object-type.ts index 47f3eb694c6..ad20103061c 100644 --- a/packages/eslint-plugin/src/rules/no-empty-object-type.ts +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -113,6 +113,7 @@ export default createRule({ 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 diff --git a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts index f8201b32c4f..5ccc5f5c181 100644 --- a/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -81,10 +81,21 @@ class Derived {} code: 'interface Base {}', errors: [ { + column: 11, data: { option: 'allowInterfaces' }, - messageId: 'noEmptyInterface', line: 1, - column: 11, + messageId: 'noEmptyInterface', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyInterface', + output: `type Base = object`, + }, + { + messageId: 'replaceEmptyInterface', + output: `type Base = unknown`, + }, + ], }, ], }, @@ -92,10 +103,21 @@ class Derived {} code: 'interface Base {}', errors: [ { + column: 11, data: { option: 'allowInterfaces' }, - messageId: 'noEmptyInterface', line: 1, - column: 11, + messageId: 'noEmptyInterface', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyInterface', + output: `type Base = object`, + }, + { + messageId: 'replaceEmptyInterface', + output: `type Base = unknown`, + }, + ], }, ], options: [{ allowInterfaces: 'never' }], @@ -112,9 +134,9 @@ class Other {} `, errors: [ { - messageId: 'noEmptyInterfaceWithSuper', - line: 6, column: 11, + line: 6, + messageId: 'noEmptyInterfaceWithSuper', suggestions: [ { messageId: 'replaceEmptyInterfaceWithSuper', @@ -144,9 +166,9 @@ class Derived {} `, errors: [ { - messageId: 'noEmptyInterfaceWithSuper', - line: 6, column: 11, + line: 6, + messageId: 'noEmptyInterfaceWithSuper', }, ], }, @@ -550,6 +572,17 @@ let value: unknown; endColumn: 15, endLine: 1, messageId: 'noEmptyInterface', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyInterface', + output: `type Base = object`, + }, + { + messageId: 'replaceEmptyInterface', + output: `type Base = unknown`, + }, + ], }, ], options: [{ allowWithName: '.*Props$' }], From 9987c2b82f6bc90a2447bb4715a6e5ecec6116b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Sat, 11 May 2024 18:22:46 -0700 Subject: [PATCH 19/21] Update packages/eslint-plugin/docs/rules/no-empty-object-type.mdx Co-authored-by: Kirk Waiblinger --- packages/eslint-plugin/docs/rules/no-empty-object-type.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 3dbb93092f5..9834313cb24 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -115,7 +115,7 @@ Whether to allow empty object type literals, as one of: ### `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 still includes a pattern of declaring empty types with `{}` instead of `object`. +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$' }`: From bd764ef647235a7212f54f683231754431c0001c Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Sat, 11 May 2024 18:22:56 -0700 Subject: [PATCH 20/21] docs touchups from Kirk --- .../eslint-plugin/docs/rules/no-empty-object-type.mdx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx index 9834313cb24..8bf941a6661 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -33,7 +33,7 @@ If you do have a use case for an API allowing `{}`, you can always configure the Note that this rule does not report on: -- `{}` as a type constituent in an intersection type (e.g. `type NonNullable = T & {}`), as this can be useful in type system operations. +- `{}` as a type constituent in an intersection type (e.g. types like TypeScript's built-in `type NonNullable = T & {}`), as this can be useful in type system operations. - Interfaces that extend from multiple other interfaces. ## Examples @@ -81,12 +81,6 @@ type TypeWith = { property: boolean }; By default, this rule flags both interfaces and object types. -:::warning -We recommend keeping all options to the default and not allowing exceptions to this rule. -The "empty object" type is often confusing even for experienced TypeScript developers. -Consider using `object` or `unknown` as a type instead. -::: - ### `allowInterfaces` Whether to allow empty interfaces, as one of: From 7dc88d75faf8fe3318463d388a24f685f2b60214 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Sat, 11 May 2024 21:46:17 -0700 Subject: [PATCH 21/21] replacedBy too --- packages/eslint-plugin/src/rules/no-empty-interface.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin/src/rules/no-empty-interface.ts b/packages/eslint-plugin/src/rules/no-empty-interface.ts index b3ada33093f..03d00c1a953 100644 --- a/packages/eslint-plugin/src/rules/no-empty-interface.ts +++ b/packages/eslint-plugin/src/rules/no-empty-interface.ts @@ -19,6 +19,7 @@ export default createRule({ description: 'Disallow the declaration of empty interfaces', }, deprecated: true, + replacedBy: ['@typescript-eslint/no-empty-object-type'], fixable: 'code', hasSuggestions: true, messages: {