diff --git a/.eslintrc.js b/.eslintrc.js index 8e62b6d06e39..da7ff36fd3b2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -308,7 +308,7 @@ module.exports = { rules: { // disallow ALL unused vars '@typescript-eslint/no-unused-vars': 'error', - '@typescript-eslint/sort-type-union-intersection-members': 'error', + '@typescript-eslint/sort-type-constituents': 'error', }, }, { diff --git a/packages/eslint-plugin/docs/rules/sort-type-constituents.md b/packages/eslint-plugin/docs/rules/sort-type-constituents.md new file mode 100644 index 000000000000..264ef2b52df9 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/sort-type-constituents.md @@ -0,0 +1,101 @@ +--- +description: 'Enforce constituents of a type union/intersection to be sorted alphabetically.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/sort-type-constituents** for documentation. + +Sorting union (`|`) and intersection (`&`) types can help: + +- keep your codebase standardized +- find repeated types +- reduce diff churn + +This rule reports on any types that aren't sorted alphabetically. + +> Types are sorted case-insensitively and treating numbers like a human would, falling back to character code sorting in case of ties. + +## Examples + + + +### ❌ Incorrect + +```ts +type T1 = B | A; + +type T2 = { b: string } & { a: string }; + +type T3 = [1, 2, 4] & [1, 2, 3]; + +type T4 = + | [1, 2, 4] + | [1, 2, 3] + | { b: string } + | { a: string } + | (() => void) + | (() => string) + | 'b' + | 'a' + | 'b' + | 'a' + | readonly string[] + | readonly number[] + | string[] + | number[] + | B + | A + | string + | any; +``` + +### ✅ Correct + +```ts +type T1 = A | B; + +type T2 = { a: string } & { b: string }; + +type T3 = [1, 2, 3] & [1, 2, 4]; + +type T4 = + | any + | string + | A + | B + | number[] + | string[] + | readonly number[] + | readonly string[] + | 'a' + | 'b' + | 'a' + | 'b' + | (() => string) + | (() => void) + | { a: string } + | { b: string } + | [1, 2, 3] + | [1, 2, 4]; +``` + +## Options + +### `groupOrder` + +Each constituent of the type is placed into a group, and then the rule sorts alphabetically within each group. +The ordering of groups is determined by this option. + +- `conditional` - Conditional types (`A extends B ? C : D`) +- `function` - Function and constructor types (`() => void`, `new () => type`) +- `import` - Import types (`import('path')`) +- `intersection` - Intersection types (`A & B`) +- `keyword` - Keyword types (`any`, `string`, etc) +- `literal` - Literal types (`1`, `'b'`, `true`, etc) +- `named` - Named types (`A`, `A['prop']`, `B[]`, `Array`) +- `object` - Object types (`{ a: string }`, `{ [key: string]: number }`) +- `operator` - Operator types (`keyof A`, `typeof B`, `readonly C[]`) +- `tuple` - Tuple types (`[A, B, C]`) +- `union` - Union types (`A | B`) +- `nullish` - `null` and `undefined` diff --git a/packages/eslint-plugin/docs/rules/sort-type-union-intersection-members.md b/packages/eslint-plugin/docs/rules/sort-type-union-intersection-members.md index 2a47547219bf..dbaf1807edff 100644 --- a/packages/eslint-plugin/docs/rules/sort-type-union-intersection-members.md +++ b/packages/eslint-plugin/docs/rules/sort-type-union-intersection-members.md @@ -6,6 +6,11 @@ description: 'Enforce members of a type union/intersection to be sorted alphabet > > See **https://typescript-eslint.io/rules/sort-type-union-intersection-members** for documentation. +:::danger Deprecated + +This rule has been renamed to [`sort-type-union-intersection-members`](./sort-type-union-intersection-members.md). +::: + Sorting union (`|`) and intersection (`&`) types can help: - keep your codebase standardized diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 1f6530ead3ec..20ea892f581d 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -154,7 +154,7 @@ export = { '@typescript-eslint/return-await': 'error', semi: 'off', '@typescript-eslint/semi': 'error', - '@typescript-eslint/sort-type-union-intersection-members': 'error', + '@typescript-eslint/sort-type-constituents': 'error', 'space-before-blocks': 'off', '@typescript-eslint/space-before-blocks': 'error', 'space-before-function-paren': 'off', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 851661400fbb..8a3c2bbf4371 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -115,6 +115,7 @@ import restrictPlusOperands from './restrict-plus-operands'; import restrictTemplateExpressions from './restrict-template-expressions'; import returnAwait from './return-await'; import semi from './semi'; +import sortTypeConstituents from './sort-type-constituents'; import sortTypeUnionIntersectionMembers from './sort-type-union-intersection-members'; import spaceBeforeBlocks from './space-before-blocks'; import spaceBeforeFunctionParen from './space-before-function-paren'; @@ -245,6 +246,7 @@ export default { 'restrict-template-expressions': restrictTemplateExpressions, 'return-await': returnAwait, semi: semi, + 'sort-type-constituents': sortTypeConstituents, 'sort-type-union-intersection-members': sortTypeUnionIntersectionMembers, 'space-before-blocks': spaceBeforeBlocks, 'space-before-function-paren': spaceBeforeFunctionParen, diff --git a/packages/eslint-plugin/src/rules/sort-type-constituents.ts b/packages/eslint-plugin/src/rules/sort-type-constituents.ts new file mode 100644 index 000000000000..92b5e44c6f98 --- /dev/null +++ b/packages/eslint-plugin/src/rules/sort-type-constituents.ts @@ -0,0 +1,269 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import * as util from '../util'; +import { getEnumNames } from '../util'; + +enum Group { + conditional = 'conditional', + function = 'function', + import = 'import', + intersection = 'intersection', + keyword = 'keyword', + nullish = 'nullish', + literal = 'literal', + named = 'named', + object = 'object', + operator = 'operator', + tuple = 'tuple', + union = 'union', +} + +function getGroup(node: TSESTree.TypeNode): Group { + switch (node.type) { + case AST_NODE_TYPES.TSConditionalType: + return Group.conditional; + + case AST_NODE_TYPES.TSConstructorType: + case AST_NODE_TYPES.TSFunctionType: + return Group.function; + + case AST_NODE_TYPES.TSImportType: + return Group.import; + + case AST_NODE_TYPES.TSIntersectionType: + return Group.intersection; + + case AST_NODE_TYPES.TSAnyKeyword: + case AST_NODE_TYPES.TSBigIntKeyword: + case AST_NODE_TYPES.TSBooleanKeyword: + case AST_NODE_TYPES.TSNeverKeyword: + case AST_NODE_TYPES.TSNumberKeyword: + case AST_NODE_TYPES.TSObjectKeyword: + case AST_NODE_TYPES.TSStringKeyword: + case AST_NODE_TYPES.TSSymbolKeyword: + case AST_NODE_TYPES.TSThisType: + case AST_NODE_TYPES.TSUnknownKeyword: + case AST_NODE_TYPES.TSIntrinsicKeyword: + return Group.keyword; + + case AST_NODE_TYPES.TSNullKeyword: + case AST_NODE_TYPES.TSUndefinedKeyword: + case AST_NODE_TYPES.TSVoidKeyword: + return Group.nullish; + + case AST_NODE_TYPES.TSLiteralType: + case AST_NODE_TYPES.TSTemplateLiteralType: + return Group.literal; + + case AST_NODE_TYPES.TSArrayType: + case AST_NODE_TYPES.TSIndexedAccessType: + case AST_NODE_TYPES.TSInferType: + case AST_NODE_TYPES.TSTypeReference: + return Group.named; + + case AST_NODE_TYPES.TSMappedType: + case AST_NODE_TYPES.TSTypeLiteral: + return Group.object; + + case AST_NODE_TYPES.TSTypeOperator: + case AST_NODE_TYPES.TSTypeQuery: + return Group.operator; + + case AST_NODE_TYPES.TSTupleType: + return Group.tuple; + + case AST_NODE_TYPES.TSUnionType: + return Group.union; + + // These types should never occur as part of a union/intersection + case AST_NODE_TYPES.TSAbstractKeyword: + case AST_NODE_TYPES.TSAsyncKeyword: + case AST_NODE_TYPES.TSDeclareKeyword: + case AST_NODE_TYPES.TSExportKeyword: + case AST_NODE_TYPES.TSNamedTupleMember: + case AST_NODE_TYPES.TSOptionalType: + case AST_NODE_TYPES.TSPrivateKeyword: + case AST_NODE_TYPES.TSProtectedKeyword: + case AST_NODE_TYPES.TSPublicKeyword: + case AST_NODE_TYPES.TSReadonlyKeyword: + case AST_NODE_TYPES.TSRestType: + case AST_NODE_TYPES.TSStaticKeyword: + case AST_NODE_TYPES.TSTypePredicate: + /* istanbul ignore next */ + throw new Error(`Unexpected Type ${node.type}`); + } +} + +function requiresParentheses(node: TSESTree.TypeNode): boolean { + return ( + node.type === AST_NODE_TYPES.TSFunctionType || + node.type === AST_NODE_TYPES.TSConstructorType + ); +} + +export type Options = [ + { + checkIntersections?: boolean; + checkUnions?: boolean; + groupOrder?: string[]; + }, +]; +export type MessageIds = 'notSorted' | 'notSortedNamed' | 'suggestFix'; + +export default util.createRule({ + name: 'sort-type-constituents', + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce constituents of a type union/intersection to be sorted alphabetically', + recommended: false, + }, + fixable: 'code', + hasSuggestions: true, + messages: { + notSorted: '{{type}} type constituents must be sorted.', + notSortedNamed: '{{type}} type {{name}} constituents must be sorted.', + suggestFix: 'Sort constituents of type (removes all comments).', + }, + schema: [ + { + type: 'object', + properties: { + checkIntersections: { + description: 'Whether to check intersection types.', + type: 'boolean', + }, + checkUnions: { + description: 'Whether to check union types.', + type: 'boolean', + }, + groupOrder: { + description: 'Ordering of the groups.', + type: 'array', + items: { + type: 'string', + enum: getEnumNames(Group), + }, + }, + }, + }, + ], + }, + defaultOptions: [ + { + checkIntersections: true, + checkUnions: true, + groupOrder: [ + Group.named, + Group.keyword, + Group.operator, + Group.literal, + Group.function, + Group.import, + Group.conditional, + Group.object, + Group.tuple, + Group.intersection, + Group.union, + Group.nullish, + ], + }, + ], + create(context, [{ checkIntersections, checkUnions, groupOrder }]) { + const sourceCode = context.getSourceCode(); + + const collator = new Intl.Collator('en', { + sensitivity: 'base', + numeric: true, + }); + + function checkSorting( + node: TSESTree.TSIntersectionType | TSESTree.TSUnionType, + ): void { + const sourceOrder = node.types.map(type => { + const group = groupOrder?.indexOf(getGroup(type)) ?? -1; + return { + group: group === -1 ? Number.MAX_SAFE_INTEGER : group, + node: type, + text: sourceCode.getText(type), + }; + }); + const expectedOrder = [...sourceOrder].sort((a, b) => { + if (a.group !== b.group) { + return a.group - b.group; + } + + return ( + collator.compare(a.text, b.text) || + (a.text < b.text ? -1 : a.text > b.text ? 1 : 0) + ); + }); + + const hasComments = node.types.some(type => { + const count = + sourceCode.getCommentsBefore(type).length + + sourceCode.getCommentsAfter(type).length; + return count > 0; + }); + + for (let i = 0; i < expectedOrder.length; i += 1) { + if (expectedOrder[i].node !== sourceOrder[i].node) { + let messageId: MessageIds = 'notSorted'; + const data = { + name: '', + type: + node.type === AST_NODE_TYPES.TSIntersectionType + ? 'Intersection' + : 'Union', + }; + if (node.parent?.type === AST_NODE_TYPES.TSTypeAliasDeclaration) { + messageId = 'notSortedNamed'; + data.name = node.parent.id.name; + } + + const fix: TSESLint.ReportFixFunction = fixer => { + const sorted = expectedOrder + .map(t => (requiresParentheses(t.node) ? `(${t.text})` : t.text)) + .join( + node.type === AST_NODE_TYPES.TSIntersectionType ? ' & ' : ' | ', + ); + + return fixer.replaceText(node, sorted); + }; + return context.report({ + node, + messageId, + data, + // don't autofix if any of the types have leading/trailing comments + // the logic for preserving them correctly is a pain - we may implement this later + ...(hasComments + ? { + suggest: [ + { + messageId: 'suggestFix', + fix, + }, + ], + } + : { fix }), + }); + } + } + } + + return { + ...(checkIntersections && { + TSIntersectionType(node): void { + checkSorting(node); + }, + }), + ...(checkUnions && { + TSUnionType(node): void { + checkSorting(node); + }, + }), + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts b/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts index ded7212c6c95..2a83b4b0525b 100644 --- a/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts +++ b/packages/eslint-plugin/src/rules/sort-type-union-intersection-members.ts @@ -114,6 +114,7 @@ export type MessageIds = 'notSorted' | 'notSortedNamed' | 'suggestFix'; export default util.createRule({ name: 'sort-type-union-intersection-members', meta: { + deprecated: true, type: 'suggestion', docs: { description: @@ -127,6 +128,7 @@ export default util.createRule({ notSortedNamed: '{{type}} type {{name}} members must be sorted.', suggestFix: 'Sort members of type (removes all comments).', }, + replacedBy: ['@typescript-eslint/sort-type-constituents'], schema: [ { type: 'object', diff --git a/packages/eslint-plugin/tests/rules/sort-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/sort-type-constituents.test.ts new file mode 100644 index 000000000000..b9989c2cf875 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/sort-type-constituents.test.ts @@ -0,0 +1,334 @@ +import type { TSESLint } from '@typescript-eslint/utils'; + +import type { + MessageIds, + Options, +} from '../../src/rules/sort-type-constituents'; +import rule from '../../src/rules/sort-type-constituents'; +import { noFormat, RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +const valid = (operator: '|' | '&'): TSESLint.ValidTestCase[] => [ + { + code: `type T = A ${operator} B;`, + }, + { + code: `type T = A ${operator} /* comment */ B;`, + }, + { + code: `type T = 'A' ${operator} 'B';`, + }, + { + code: `type T = 1 ${operator} 2;`, + }, + { + code: noFormat`type T = (A) ${operator} (B);`, + }, + { + code: `type T = { a: string } ${operator} { b: string };`, + }, + { + code: `type T = [1, 2, 3] ${operator} [1, 2, 4];`, + }, + { + code: `type T = (() => string) ${operator} (() => void);`, + }, + { + code: `type T = () => string ${operator} void;`, + }, + { + // testing the default ordering + code: noFormat` +type T = + ${operator} A + ${operator} B + ${operator} intrinsic + ${operator} number[] + ${operator} string[] + ${operator} any + ${operator} string + ${operator} symbol + ${operator} this + ${operator} readonly number[] + ${operator} readonly string[] + ${operator} 'a' + ${operator} 'b' + ${operator} "a" + ${operator} "b" + ${operator} (() => string) + ${operator} (() => void) + ${operator} (new () => string) + ${operator} (new () => void) + ${operator} import('bar') + ${operator} import('foo') + ${operator} (number extends string ? unknown : never) + ${operator} (string extends string ? unknown : never) + ${operator} { [a in string]: string } + ${operator} { [a: string]: string } + ${operator} { [b in string]: string } + ${operator} { [b: string]: string } + ${operator} { a: string } + ${operator} { b: string } + ${operator} [1, 2, 3] + ${operator} [1, 2, 4] + ${operator} (A & B) + ${operator} (B & C) + ${operator} (A | B) + ${operator} (B | C) + ${operator} null + ${operator} undefined + `, + }, +]; +const invalid = ( + operator: '|' | '&', +): TSESLint.InvalidTestCase[] => { + const type = operator === '|' ? 'Union' : 'Intersection'; + return [ + { + code: `type T = B ${operator} A;`, + output: `type T = A ${operator} B;`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = 'B' ${operator} 'A';`, + output: `type T = 'A' ${operator} 'B';`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = 2 ${operator} 1;`, + output: `type T = 1 ${operator} 2;`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: noFormat`type T = (B) ${operator} (A);`, + output: `type T = A ${operator} B;`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = { b: string } ${operator} { a: string };`, + output: `type T = { a: string } ${operator} { b: string };`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = [1, 2, 4] ${operator} [1, 2, 3];`, + output: `type T = [1, 2, 3] ${operator} [1, 2, 4];`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = (() => void) ${operator} (() => string);`, + output: `type T = (() => string) ${operator} (() => void);`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = () => void ${operator} string;`, + output: `type T = () => string ${operator} void;`, + errors: [ + { + messageId: 'notSorted', + data: { + type, + }, + }, + ], + }, + { + code: `type T = () => undefined ${operator} null;`, + output: `type T = () => null ${operator} undefined;`, + errors: [ + { + messageId: 'notSorted', + data: { + type, + }, + }, + ], + }, + { + code: noFormat` +type T = + ${operator} [1, 2, 4] + ${operator} [1, 2, 3] + ${operator} { b: string } + ${operator} { a: string } + ${operator} (() => void) + ${operator} (() => string) + ${operator} "b" + ${operator} "a" + ${operator} 'b' + ${operator} 'a' + ${operator} readonly string[] + ${operator} readonly number[] + ${operator} string[] + ${operator} number[] + ${operator} B + ${operator} A + ${operator} undefined + ${operator} null + ${operator} string + ${operator} any; + `, + output: ` +type T = + A ${operator} B ${operator} number[] ${operator} string[] ${operator} any ${operator} string ${operator} readonly number[] ${operator} readonly string[] ${operator} 'a' ${operator} 'b' ${operator} "a" ${operator} "b" ${operator} (() => string) ${operator} (() => void) ${operator} { a: string } ${operator} { b: string } ${operator} [1, 2, 3] ${operator} [1, 2, 4] ${operator} null ${operator} undefined; + `, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + }, + ], + }, + { + code: `type T = B ${operator} /* comment */ A;`, + output: null, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + suggestions: [ + { + messageId: 'suggestFix', + output: `type T = A ${operator} B;`, + }, + ], + }, + ], + }, + { + code: `type T = (() => /* comment */ A) ${operator} B;`, + output: `type T = B ${operator} (() => /* comment */ A);`, + errors: [ + { + messageId: 'notSortedNamed', + data: { + type, + name: 'T', + }, + suggestions: null, + }, + ], + }, + { + code: `type Expected = (new (x: number) => boolean) ${operator} string;`, + output: `type Expected = string ${operator} (new (x: number) => boolean);`, + errors: [ + { + messageId: 'notSortedNamed', + }, + ], + }, + ]; +}; + +ruleTester.run('sort-type-constituents', rule, { + valid: [ + ...valid('|'), + { + code: 'type T = B | A;', + options: [ + { + checkUnions: false, + }, + ], + }, + + ...valid('&'), + { + code: 'type T = B & A;', + options: [ + { + checkIntersections: false, + }, + ], + }, + + { + code: noFormat` +type T = [1] | 'a' | 'b' | "b" | 1 | 2 | {}; + `, + options: [ + { + groupOrder: ['tuple', 'literal', 'object'], + }, + ], + }, + { + // if not specified - groups should be placed last + code: ` +type T = 1 | string | {} | A; + `, + options: [ + { + groupOrder: ['literal', 'keyword'], + }, + ], + }, + ], + invalid: [...invalid('|'), ...invalid('&')], +}); pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy