From 30186458dba498311de81f330ca6ae5c1d3a2f6c Mon Sep 17 00:00:00 2001 From: Vincent Girard Date: Thu, 27 Jun 2024 13:13:03 +0000 Subject: [PATCH 1/4] Start no-empty-object-type --- .../docs/rules/no-empty-object-type.mdx | 171 ++++++ packages/eslint-plugin/src/configs/all.ts | 1 + packages/eslint-plugin/src/rules/index.ts | 2 + .../src/rules/no-empty-object-type.ts | 186 ++++++ .../tests/rules/no-empty-object-type.test.ts | 574 ++++++++++++++++++ 5 files changed, 934 insertions(+) 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/rules/no-empty-object-type.test.ts 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 000000000000..732f31aca267 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -0,0 +1,171 @@ +--- +description: 'Disallow accidentally using the "empty object" type.' +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-empty-object-type** for documentation. + +The `{}`, or "empty object" type in TypeScript is a common source of confusion for developers unfamiliar with TypeScript's structural typing. +`{}` represents any _non-nullish value_, including literals like `0` and `""`: + +```ts +let anyNonNullishValue: {} = 'Intentionally allowed by TypeScript.'; +``` + +Often, developers writing `{}` actually mean either: + +- `object`: representing any _object_ value +- `unknown`: representing any value at all, including `null` and `undefined` + +In other words, the "empty object" type `{}` really means _"any value that is defined"_. +That includes arrays, class instances, functions, and primitives such as `string` and `symbol`. + +To avoid confusion around the `{}` type allowing any _non-nullish value_, this rule bans usage of the `{}` type. +That includes interfaces and object type aliases with no fields. + +:::tip +If you do have a use case for an API allowing `{}`, you can always configure the [rule's options](#options), use an [ESLint disable comment](https://eslint.org/docs/latest/use/configure/rules#using-configuration-comments-1), or [disable the rule in your ESLint config](https://eslint.org/docs/latest/use/configure/rules#using-configuration-files-1). +::: + +Note that this rule does not report on: + +- `{}` as a type constituent in an intersection type (e.g. types like TypeScript's built-in `type NonNullable = T & {}`), as this can be useful in type system operations. +- Interfaces that extend from multiple other interfaces. + +## Examples + + + + +```ts +let anyObject: {}; +let anyValue: {}; + +interface AnyObjectA {} +interface AnyValueA {} + +type AnyObjectB = {}; +type AnyValueB = {}; +``` + + + + +```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. + +### `allowInterfaces` + +Whether to allow empty interfaces, as one of: + +- `'always'`: to always allow interfaces with no fields +- `'never'` _(default)_: to never allow interfaces with no fields +- `'with-single-extends'`: to allow empty interfaces that `extend` from a single base interface + +Examples of code for this rule with `{ allowInterfaces: 'with-single-extends' }`: + + + +```ts option='{ "allowInterfaces": "with-single-extends" }' showPlaygroundButton +interface Foo {} +``` + + +```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 + +Examples of code for this rule with `{ allowObjectTypes: 'always' }`: + + + +```ts option='{ "allowObjectTypes": "always" }' showPlaygroundButton +interface Base {} +```` + + + +```ts option='{ "allowObjectTypes": "always" }' showPlaygroundButton +type Base = {}; +``` + + + +### `allowWithName` + +A stringified regular expression to allow interfaces and object type aliases with the configured name. +This can be useful if your existing code style includes a pattern of declaring empty types with `{}` instead of `object`. + +Examples of code for this rule with `{ allowWithName: 'Props$' }`: + + + + +```ts option='{ "allowWithName": "Props$" }' showPlaygroundButton +interface InterfaceValue {} + +type TypeValue = {}; +``` + + + + +```ts option='{ "allowWithName": "Props$" }' showPlaygroundButton +interface InterfaceProps {} + +type TypeProps = {}; +``` + + + + +## 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) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index ee778e7e48cd..228b01034839 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/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index fe0d36026bd1..62358555d7f3 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -47,6 +47,7 @@ import noDuplicateTypeConstituents from './no-duplicate-type-constituents'; import noDynamicDelete from './no-dynamic-delete'; import noEmptyFunction from './no-empty-function'; import noEmptyInterface from './no-empty-interface'; +import noEmptyObjectType from './no-empty-object-type'; import noExplicitAny from './no-explicit-any'; import noExtraNonNullAssertion from './no-extra-non-null-assertion'; import noExtraParens from './no-extra-parens'; @@ -193,6 +194,7 @@ export default { 'no-dynamic-delete': noDynamicDelete, 'no-empty-function': noEmptyFunction, 'no-empty-interface': noEmptyInterface, + 'no-empty-object-type': noEmptyObjectType, 'no-explicit-any': noExplicitAny, 'no-extra-non-null-assertion': noExtraNonNullAssertion, 'no-extra-parens': noExtraParens, 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 000000000000..21db86ba2033 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-empty-object-type.ts @@ -0,0 +1,186 @@ +import type { TSESLint } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { createRule } from '../util'; + +export type AllowInterfaces = 'always' | 'never' | 'with-single-extends'; + +export type AllowObjectTypes = 'always' | 'never'; + +export type Options = [ + { + allowInterfaces?: AllowInterfaces; + allowObjectTypes?: AllowObjectTypes; + allowWithName?: string; + }, +]; + +export type MessageIds = + | 'noEmptyInterface' + | 'noEmptyObject' + | 'noEmptyInterfaceWithSuper' + | 'replaceEmptyInterface' + | 'replaceEmptyInterfaceWithSuper' + | 'replaceEmptyObjectType'; + +const noEmptyMessage = (emptyType: string): string => + [ + `${emptyType} allows any non-nullish value, including literals like \`0\` and \`""\`.`, + "- If that's what you want, disable this lint rule with an inline comment or configure the '{{ option }}' rule option.", + '- If you want a type meaning "any object", you probably want `object` instead.', + '- If you want a type meaning "any value", you probably want `unknown` instead.', + ].join('\n'); + +export default createRule({ + name: 'no-empty-object-type', + meta: { + type: 'suggestion', + docs: { + description: 'Disallow accidentally using the "empty object" type', + }, + hasSuggestions: true, + messages: { + noEmptyInterface: noEmptyMessage('An empty interface declaration'), + noEmptyObject: noEmptyMessage('The `{}` ("empty object") type'), + noEmptyInterfaceWithSuper: + 'An interface declaring no members is equivalent to its supertype.', + replaceEmptyInterface: 'Replace empty interface with `{{replacement}}`.', + replaceEmptyInterfaceWithSuper: + 'Replace empty interface with a type alias.', + replaceEmptyObjectType: 'Replace `{}` with `{{replacement}}`.', + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + allowInterfaces: { + enum: ['always', 'never', 'with-single-extends'], + type: 'string', + }, + allowObjectTypes: { + enum: ['always', 'in-type-alias-with-name', 'never'], + type: 'string', + }, + allowWithName: { + type: 'string', + }, + }, + }, + ], + }, + defaultOptions: [ + { + allowInterfaces: 'never', + allowObjectTypes: 'never', + }, + ], + create(context, [{ allowInterfaces, allowWithName, allowObjectTypes }]) { + const allowWithNameTester = allowWithName + ? new RegExp(allowWithName, 'u') + : undefined; + + return { + ...(allowInterfaces !== 'always' && { + TSInterfaceDeclaration(node): void { + if (allowWithNameTester?.test(node.id.name)) { + return; + } + + const extend = node.extends; + if ( + node.body.body.length !== 0 || + (extend.length === 1 && + allowInterfaces === 'with-single-extends') || + extend.length > 1 + ) { + return; + } + + const scope = context.sourceCode.getScope(node); + + const mergedWithClassDeclaration = scope.set + .get(node.id.name) + ?.defs.some( + def => def.node.type === AST_NODE_TYPES.ClassDeclaration, + ); + + if (extend.length === 0) { + context.report({ + data: { option: 'allowInterfaces' }, + node: node.id, + messageId: 'noEmptyInterface', + ...(!mergedWithClassDeclaration && { + suggest: ['object', 'unknown'].map(replacement => ({ + data: { replacement }, + fix(fixer): TSESLint.RuleFix { + const id = context.sourceCode.getText(node.id); + const typeParam = node.typeParameters + ? context.sourceCode.getText(node.typeParameters) + : ''; + + return fixer.replaceText( + node, + `type ${id}${typeParam} = ${replacement}`, + ); + }, + messageId: 'replaceEmptyInterface', + })), + }), + }); + return; + } + + context.report({ + node: node.id, + messageId: 'noEmptyInterfaceWithSuper', + ...(!mergedWithClassDeclaration && { + suggest: [ + { + fix(fixer): TSESLint.RuleFix { + const extended = context.sourceCode.getText(extend[0]); + const id = context.sourceCode.getText(node.id); + const typeParam = node.typeParameters + ? context.sourceCode.getText(node.typeParameters) + : ''; + + return fixer.replaceText( + node, + `type ${id}${typeParam} = ${extended}`, + ); + }, + messageId: 'replaceEmptyInterfaceWithSuper', + }, + ], + }), + }); + }, + }), + ...(allowObjectTypes !== 'always' && { + TSTypeLiteral(node): void { + if ( + node.members.length || + node.parent.type === AST_NODE_TYPES.TSIntersectionType || + (allowWithNameTester && + node.parent.type === AST_NODE_TYPES.TSTypeAliasDeclaration && + allowWithNameTester.test(node.parent.id.name)) + ) { + return; + } + + context.report({ + data: { option: 'allowObjectTypes' }, + messageId: 'noEmptyObject', + node, + suggest: ['object', 'unknown'].map(replacement => ({ + data: { replacement }, + messageId: 'replaceEmptyObjectType', + fix: (fixer): TSESLint.RuleFix => + fixer.replaceText(node, replacement), + })), + }); + }, + }), + }; + }, +}); 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 000000000000..8bc0928ca1ba --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-empty-object-type.test.ts @@ -0,0 +1,574 @@ +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: [ + ` +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' }], + }, + { + code: 'type Base = {};', + options: [{ allowWithName: 'Base' }], + }, + { + code: 'type BaseProps = {};', + options: [{ allowWithName: 'Props$' }], + }, + { + code: 'interface Base {}', + options: [{ allowWithName: 'Base' }], + }, + { + code: 'interface BaseProps {}', + options: [{ allowWithName: 'Props$' }], + }, + ], + invalid: [ + { + code: 'interface Base {}', + errors: [ + { + column: 11, + data: { option: 'allowInterfaces' }, + line: 1, + messageId: 'noEmptyInterface', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyInterface', + output: `type Base = object`, + }, + { + messageId: 'replaceEmptyInterface', + output: `type Base = unknown`, + }, + ], + }, + ], + }, + { + code: 'interface Base {}', + errors: [ + { + column: 11, + data: { option: 'allowInterfaces' }, + line: 1, + messageId: 'noEmptyInterface', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyInterface', + output: `type Base = object`, + }, + { + messageId: 'replaceEmptyInterface', + output: `type Base = unknown`, + }, + ], + }, + ], + options: [{ allowInterfaces: 'never' }], + }, + { + code: ` +interface Base { + props: string; +} +interface Derived extends Base {} +class Other {} + `, + errors: [ + { + line: 5, + column: 11, + messageId: 'noEmptyInterfaceWithSuper', + 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: [ + { + line: 5, + column: 11, + messageId: 'noEmptyInterfaceWithSuper', + }, + ], + }, + { + code: ` +interface Base { + props: string; +} +interface Derived extends Base {} +const derived = class Derived {}; + `, + errors: [ + { + messageId: 'noEmptyInterfaceWithSuper', + line: 5, + 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: 5, + 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: 'noEmptyObject', + 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: 'noEmptyObject', + 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: 'noEmptyObject', + 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: 'noEmptyObject', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: unknown;', + }, + ], + }, + ], + }, + { + code: 'let value: {};', + errors: [ + { + column: 12, + line: 1, + endColumn: 14, + endLine: 1, + data: { option: 'allowObjectTypes' }, + messageId: 'noEmptyObject', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'let value: unknown;', + }, + ], + }, + ], + options: [{ allowObjectTypes: 'never' }], + }, + { + code: ` +let value: { + /* ... */ +}; + `, + errors: [ + { + line: 2, + endLine: 4, + column: 12, + endColumn: 2, + data: { option: 'allowObjectTypes' }, + messageId: 'noEmptyObject', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: ` +let value: object; + `, + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: ` +let value: unknown; + `, + }, + ], + }, + ], + }, + { + code: 'type MyUnion = T | {};', + errors: [ + { + column: 23, + line: 1, + endColumn: 25, + endLine: 1, + data: { option: 'allowObjectTypes' }, + messageId: 'noEmptyObject', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyObjectType', + output: 'type MyUnion = T | object;', + }, + { + data: { replacement: 'unknown' }, + messageId: 'replaceEmptyObjectType', + output: 'type MyUnion = T | unknown;', + }, + ], + }, + ], + }, + { + code: 'type Base = {} | null;', + errors: [ + { + column: 13, + line: 1, + endColumn: 15, + endLine: 1, + messageId: 'noEmptyObject', + }, + ], + 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', + suggestions: [ + { + data: { replacement: 'object' }, + messageId: 'replaceEmptyInterface', + output: `type Base = object`, + }, + { + messageId: 'replaceEmptyInterface', + output: `type Base = unknown`, + }, + ], + }, + ], + options: [{ allowWithName: '.*Props$' }], + }, + ], +}); From 983ae70ddb6f486f13b3f2d8a0d7ef091edd71ea Mon Sep 17 00:00:00 2001 From: Vincent Girard Date: Thu, 27 Jun 2024 13:16:21 +0000 Subject: [PATCH 2/4] Snapshots --- .../no-empty-object-type.shot | 136 ++++++++++++++++++ .../no-empty-object-type.shot | 38 +++++ 2 files changed, 174 insertions(+) create mode 100644 packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot create mode 100644 packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot 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 000000000000..0c63badf7756 --- /dev/null +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-empty-object-type.shot @@ -0,0 +1,136 @@ +// 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 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 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. + +interface AnyObjectA {} + ~~~~~~~~~~ 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 {} + ~~~~~~~~~ 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 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 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 2`] = ` +"Correct + +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 }; +" +`; + +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 {} +" +`; + +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 = {}; +" +`; + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 6`] = ` +"Correct +Options: { "allowObjectTypes": "always" } + +type Base = {}; +" +`; + +exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 7`] = ` +"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 8`] = ` +"Correct +Options: { "allowWithName": "Props$" } + +interface InterfaceProps {} + +type TypeProps = {}; +" +`; 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 000000000000..632b3ce83ca9 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-empty-object-type.shot @@ -0,0 +1,38 @@ +// 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: + +[ + { + "additionalProperties": false, + "properties": { + "allowInterfaces": { + "enum": ["always", "never", "with-single-extends"], + "type": "string" + }, + "allowObjectTypes": { + "enum": ["always", "in-type-alias-with-name", "never"], + "type": "string" + }, + "allowWithName": { + "type": "string" + } + }, + "type": "object" + } +] + + +# TYPES: + +type Options = [ + { + allowInterfaces?: 'always' | 'never' | 'with-single-extends'; + allowObjectTypes?: 'always' | 'in-type-alias-with-name' | 'never'; + allowWithName?: string; + }, +]; +" +`; From 07d597ee4a4f335eb13b76f8e5034d112d11d192 Mon Sep 17 00:00:00 2001 From: Vincent Girard Date: Thu, 27 Jun 2024 14:31:43 +0000 Subject: [PATCH 3/4] Fixed tests and doc --- .../docs/rules/no-empty-object-type.mdx | 22 +++++---- .../no-empty-object-type.shot | 45 +++++++++---------- .../tests/rules/no-empty-object-type.test.ts | 29 +++++++++--- 3 files changed, 58 insertions(+), 38 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 732f31aca267..6a0e27bd8834 100644 --- a/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx +++ b/packages/eslint-plugin/docs/rules/no-empty-object-type.mdx @@ -93,19 +93,22 @@ Examples of code for this rule with `{ allowInterfaces: 'with-single-extends' }` -```ts option='{ "allowInterfaces": "with-single-extends" }' showPlaygroundButton + +```ts option='{ "allowInterfaces": "with-single-extends" }' interface Foo {} ``` + -```ts option='{ "allowInterfaces": "with-single-extends" }' showPlaygroundButton + +```ts option='{ "allowInterfaces": "with-single-extends" }' interface Base { value: boolean; } interface Derived extends Base {} +``` -```` @@ -120,15 +123,18 @@ Examples of code for this rule with `{ allowObjectTypes: 'always' }`: -```ts option='{ "allowObjectTypes": "always" }' showPlaygroundButton + +```ts option='{ "allowObjectTypes": "always" }' interface Base {} -```` +``` -```ts option='{ "allowObjectTypes": "always" }' showPlaygroundButton + +```ts option='{ "allowObjectTypes": "always" }' type Base = {}; ``` + @@ -142,7 +148,7 @@ Examples of code for this rule with `{ allowWithName: 'Props$' }`: -```ts option='{ "allowWithName": "Props$" }' showPlaygroundButton +```ts option='{ "allowWithName": "Props$" }' interface InterfaceValue {} type TypeValue = {}; @@ -151,7 +157,7 @@ type TypeValue = {}; -```ts option='{ "allowWithName": "Props$" }' showPlaygroundButton +```ts option='{ "allowWithName": "Props$" }' 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 0c63badf7756..1dacb3b537d8 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 @@ -53,7 +53,7 @@ type AnyValueB = unknown; let objectWith: { property: boolean }; interface InterfaceWith { -property: boolean; + property: boolean; } type TypeWith = { property: boolean }; @@ -61,41 +61,38 @@ 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; -} +"Incorrect +Options: { "allowInterfaces": "with-single-extends" } -interface Derived extends Base {} +interface Foo {} + ~~~ 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. " `; exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 4`] = ` -"Incorrect -Options: { "allowWithName": "Props$" } +"Correct +Options: { "allowInterfaces": "with-single-extends" } -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. +interface Base { + value: boolean; +} -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. +interface Derived extends Base {} " `; exports[`Validating rule docs no-empty-object-type.mdx code examples ESLint output 5`] = ` -"Correct -Options: { "allowWithName": "Props$" } - -interface InterfaceProps {} +"Incorrect +Options: { "allowObjectTypes": "always" } -type TypeProps = {}; +interface Base {} + ~~~~ 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. " `; 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 8bc0928ca1ba..6494b8d6372f 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 @@ -17,9 +17,11 @@ interface Base { 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 {} `, @@ -32,6 +34,7 @@ interface Both extends Base, Derived {} interface Base { name: string; } + interface Derived extends Base {} `, options: [{ allowInterfaces: 'with-single-extends' }], @@ -41,7 +44,9 @@ interface Derived extends Base {} interface Base { props: string; } + interface Derived extends Base {} + class Derived {} `, options: [{ allowInterfaces: 'with-single-extends' }], @@ -76,9 +81,9 @@ class Derived {} code: 'interface Base {}', errors: [ { + line: 1, column: 11, data: { option: 'allowInterfaces' }, - line: 1, messageId: 'noEmptyInterface', suggestions: [ { @@ -98,9 +103,9 @@ class Derived {} code: 'interface Base {}', errors: [ { + line: 1, column: 11, data: { option: 'allowInterfaces' }, - line: 1, messageId: 'noEmptyInterface', suggestions: [ { @@ -122,12 +127,14 @@ class Derived {} interface Base { props: string; } + interface Derived extends Base {} + class Other {} `, errors: [ { - line: 5, + line: 6, column: 11, messageId: 'noEmptyInterfaceWithSuper', suggestions: [ @@ -137,7 +144,9 @@ class Other {} interface Base { props: string; } + type Derived = Base + class Other {} `, }, @@ -150,12 +159,14 @@ class Other {} interface Base { props: string; } + interface Derived extends Base {} + class Derived {} `, errors: [ { - line: 5, + line: 6, column: 11, messageId: 'noEmptyInterfaceWithSuper', }, @@ -166,13 +177,15 @@ class Derived {} interface Base { props: string; } + interface Derived extends Base {} + const derived = class Derived {}; `, errors: [ { messageId: 'noEmptyInterfaceWithSuper', - line: 5, + line: 6, column: 11, suggestions: [ { @@ -181,7 +194,9 @@ const derived = class Derived {}; interface Base { props: string; } + type Derived = Base + const derived = class Derived {}; `, }, @@ -194,12 +209,13 @@ const derived = class Derived {}; interface Base { name: string; } + interface Derived extends Base {} `, errors: [ { messageId: 'noEmptyInterfaceWithSuper', - line: 5, + line: 6, column: 11, suggestions: [ { @@ -208,6 +224,7 @@ interface Derived extends Base {} interface Base { name: string; } + type Derived = Base `, }, From 3cd8a82544d4b948ffe191c7b9404801bbc621a6 Mon Sep 17 00:00:00 2001 From: Vincent Girard Date: Thu, 27 Jun 2024 14:50:19 +0000 Subject: [PATCH 4/4] Forgotten to add to all rules --- packages/typescript-eslint/src/configs/all.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/typescript-eslint/src/configs/all.ts b/packages/typescript-eslint/src/configs/all.ts index c92e80a1f275..f14a7a51cf45 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',