Repository: azat-io/eslint-plugin-perfectionist
Pull request #535 (2025-04-29 21:59:51)

feat(sort-union-types): migrates `groups` to new API
### Description

This PR:
- migrates the `sort-union-types` and `sort-intersection-types`'s `groups` to the new API.
- adds the `customGroups` option.

There is no functional change: the list of selectors is the same as the list of predefine groups before.

### What is the purpose of this pull request?

- [x] New Feature


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/535?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`c7f62a0`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/c7f62a06854fe2e52418a5791b10db677c783bff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) to head [(`d20b267`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/d20b267b38d0090367e2a321aaf37b1d9aee280b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #535   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           98        99    +1     
  Lines         7992      8020   +28     
  Branches      1621      1621           
=========================================
+ Hits          7992      8020   +28     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/535?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/docs/content/rules/sort-intersection-types.mdx b/docs/content/rules/sort-intersection-types.mdx
index 58aff1e..ec30e74 100644
--- a/docs/content/rules/sort-intersection-types.mdx
+++ b/docs/content/rules/sort-intersection-types.mdx
@@ -215,9 +215,17 @@ This option is only applicable when [`partitionByNewLine`](#partitionbynewline)
 </sub>
 <sub>default: `[]`</sub>
 
-Specifies a list of intersection type groups for sorting. Groups help organize types into categories, making your type definitions more readable and maintainable.
+Each member will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found).
+The order of items in the `groups` option determines how groups are ordered.
+
+Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options.
 
-Predefined groups:
+Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter.
+All members of the groups in the array will be sorted together as if they were part of a single group.
+
+Predefined groups are characterized by a single selector.
+
+##### List of selectors
 
 - `'conditional`' — Conditional types.
 - `'function`' — Function types.
@@ -233,19 +241,14 @@ Predefined groups:
 - `'nullish`' — Nullish types (`null` or `undefined`).
 - `'unknown`' — Types that don’t fit into any group specified in the `groups` option.
 
-If the `unknown` group is not specified in the `groups` option, it will automatically be added to the end of the list.
-
-Each intersection type will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found).
-The order of items in the `groups` option determines how groups are ordered.
-
-Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options.
+##### The `unknown` group
 
-Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter.
-All members of the groups in the array will be sorted together as if they were part of a single group.
+Members that don’t fit into any group specified in the `groups` option will be placed in the `unknown` group. If the `unknown` group is not specified in the `groups` option,
+it will automatically be added to the end of the list.
 
 #### Example 1
 
-Using all predefined groups:
+Using all selectors:
 
 ```ts
 type Example =
@@ -345,6 +348,66 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline)
 }
 ```
 
+### customGroups
+
+<sub>
+  type: `Array<CustomGroupDefinition | CustomGroupAnyOfDefinition>`
+</sub>
+<sub>default: `[]`</sub>
+
+Defines custom groups to match specific object type members.
+
+A custom group definition may follow one of the two following interfaces:
+
+```ts
+interface CustomGroupDefinition {
+  groupName: string
+  type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
+  order?: 'asc' | 'desc'
+  fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
+  selector?: string
+  elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+}
+
+```
+An type member will match a `CustomGroupDefinition` group if it matches all the filters of the custom group's definition.
+
+or:
+
+```ts
+interface CustomGroupAnyOfDefinition {
+  groupName: string
+  type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
+  order?: 'asc' | 'desc'
+  fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
+  anyOf: Array<{
+      selector?: string
+      elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+  }>
+}
+```
+
+An type member will match a `CustomGroupAnyOfDefinition` group if it matches all the filters of at least one of the `anyOf` items.
+
+#### Attributes
+
+- `groupName` — The group's name, which needs to be put in the [`groups`](#groups) option.
+- `selector` — Filter on the `selector` of the element.
+- `elementNamePattern` — If entered, will check that the name of the element matches the pattern entered.
+- `type` — Overrides the [`type`](#type) option for that custom group. `unsorted` will not sort the group.
+- `order` — Overrides the [`order`](#order) option for that custom group.
+- `fallbackSort` — Overrides the [`fallbackSort`](#fallbacksort) option for that custom group.
+- `newlinesInside` — Enforces a specific newline behavior between elements of the group.
+
+#### Match importance
+
+The `customGroups` list is ordered:
+The first custom group definition that matches an element will be used.
+
+Custom groups have a higher priority than any predefined group.
+
 ## Usage
 
 <CodeTabs
@@ -372,6 +435,7 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline)
                   partitionByNewLine: false,
                   newlinesBetween: 'ignore',
                   groups: [],
+                  customGroups: [],
                 },
               ],
             },
@@ -401,6 +465,7 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline)
                 partitionByNewLine: false,
                 newlinesBetween: 'ignore',
                 groups: [],
+                customGroups: [],
               },
             ],
           },
diff --git a/docs/content/rules/sort-union-types.mdx b/docs/content/rules/sort-union-types.mdx
index 67da385..8e884ee 100644
--- a/docs/content/rules/sort-union-types.mdx
+++ b/docs/content/rules/sort-union-types.mdx
@@ -237,7 +237,17 @@ This option is only applicable when [`partitionByNewLine`](#partitionbynewline)
 
 Specifies a list of union type groups for sorting. Groups help organize types into categories, making your type definitions more readable and maintainable.
 
-Predefined groups:
+Each member will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found).
+The order of items in the `groups` option determines how groups are ordered.
+
+Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options.
+
+Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter.
+All members of the groups in the array will be sorted together as if they were part of a single group.
+
+Predefined groups are characterized by a single selector.
+
+##### List of selectors
 
 - `'conditional`' — Conditional types.
 - `'function`' — Function types.
@@ -253,19 +263,14 @@ Predefined groups:
 - `'nullish`' — Nullish types (`null` or `undefined`).
 - `'unknown`' — Types that don’t fit into any group entered by the user.
 
-If the `unknown` group is not specified in the `groups` option, it will automatically be added to the end of the list.
+##### The `unknown` group
 
-Each union type will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found).
-The order of items in the `groups` option determines how groups are ordered.
-
-Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options.
-
-Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter.
-All members of the groups in the array will be sorted together as if they were part of a single group.
+Members that don’t fit into any group specified in the `groups` option will be placed in the `unknown` group. If the `unknown` group is not specified in the `groups` option,
+it will automatically be added to the end of the list.
 
 #### Example 1
 
-Using all predefined groups:
+Using all selectors:
 
 ```ts
 type Example =
@@ -365,6 +370,66 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline)
 }
 ```
 
+### customGroups
+
+<sub>
+  type: `Array<CustomGroupDefinition | CustomGroupAnyOfDefinition>`
+</sub>
+<sub>default: `[]`</sub>
+
+Defines custom groups to match specific object type members.
+
+A custom group definition may follow one of the two following interfaces:
+
+```ts
+interface CustomGroupDefinition {
+  groupName: string
+  type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
+  order?: 'asc' | 'desc'
+  fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
+  selector?: string
+  elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+}
+
+```
+An type member will match a `CustomGroupDefinition` group if it matches all the filters of the custom group's definition.
+
+or:
+
+```ts
+interface CustomGroupAnyOfDefinition {
+  groupName: string
+  type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
+  order?: 'asc' | 'desc'
+  fallbackSort?: { type: string; order?: 'asc' | 'desc' }
+  newlinesInside?: 'always' | 'never'
+  anyOf: Array<{
+      selector?: string
+      elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+  }>
+}
+```
+
+An type member will match a `CustomGroupAnyOfDefinition` group if it matches all the filters of at least one of the `anyOf` items.
+
+#### Attributes
+
+- `groupName` — The group's name, which needs to be put in the [`groups`](#groups) option.
+- `selector` — Filter on the `selector` of the element.
+- `elementNamePattern` — If entered, will check that the name of the element matches the pattern entered.
+- `type` — Overrides the [`type`](#type) option for that custom group. `unsorted` will not sort the group.
+- `order` — Overrides the [`order`](#order) option for that custom group.
+- `fallbackSort` — Overrides the [`fallbackSort`](#fallbacksort) option for that custom group.
+- `newlinesInside` — Enforces a specific newline behavior between elements of the group.
+
+#### Match importance
+
+The `customGroups` list is ordered:
+The first custom group definition that matches an element will be used.
+
+Custom groups have a higher priority than any predefined group.
+
 ## Usage
 
 <CodeTabs
@@ -392,6 +457,7 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline)
                   partitionByNewLine: false,
                   newlinesBetween: 'ignore',
                   groups: [],
+                  customGroups: [],
                 },
               ],
             },
@@ -421,6 +487,7 @@ This feature is only applicable when [`partitionByNewLine`](#partitionbynewline)
                 partitionByNewLine: false,
                 newlinesBetween: 'ignore',
                 groups: [],
+                customGroups: [],
               },
             ],
           },
diff --git a/rules/sort-heritage-clauses.ts b/rules/sort-heritage-clauses.ts
index d4deb36..174748c 100644
--- a/rules/sort-heritage-clauses.ts
+++ b/rules/sort-heritage-clauses.ts
@@ -118,7 +118,7 @@ let sortHeritageClauses = (
     sourceCode,
   })
 
-  let nodes: SortingNode[] = heritageClauses!.map(heritageClause => {
+  let nodes: SortingNode[] = heritageClauses.map(heritageClause => {
     let name = getHeritageClauseExpressionName(heritageClause.expression)
 
     let group = computeGroup({
diff --git a/rules/sort-intersection-types.ts b/rules/sort-intersection-types.ts
index a05872d..36ae0cf 100644
--- a/rules/sort-intersection-types.ts
+++ b/rules/sort-intersection-types.ts
@@ -1,4 +1,4 @@
-import type { Options as SortUnionTypesOptions } from './sort-union-types'
+import type { Options as SortUnionTypesOptions } from './sort-union-types/types'
 
 import {
   MISSED_SPACING_ERROR,
@@ -26,6 +26,7 @@ let defaultOptions: Required<Options[0]> = {
   type: 'alphabetical',
   ignoreCase: true,
   locales: 'en-US',
+  customGroups: [],
   alphabet: '',
   order: 'asc',
   groups: [],
diff --git a/rules/sort-union-types.ts b/rules/sort-union-types.ts
index 7401c85..d18a2eb 100644
--- a/rules/sort-union-types.ts
+++ b/rules/sort-union-types.ts
@@ -2,15 +2,11 @@ import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
 import type { RuleContext } from '@typescript-eslint/utils/ts-eslint'
 import type { TSESTree } from '@typescript-eslint/types'
 
-import type {
-  PartitionByCommentOption,
-  NewlinesBetweenOption,
-  CommonOptions,
-  GroupsOptions,
-} from '../types/common-options'
+import type { Selector, Options } from './sort-union-types/types'
 import type { SortingNode } from '../types/sorting-node'
 
 import {
+  buildCustomGroupsArrayJsonSchema,
   partitionByCommentJsonSchema,
   partitionByNewLineJsonSchema,
   newlinesBetweenJsonSchema,
@@ -24,44 +20,28 @@ import {
   ORDER_ERROR,
 } from '../utils/report-errors'
 import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration'
+import { buildGetCustomGroupOverriddenOptionsFunction } from '../utils/get-custom-groups-compare-options'
 import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration'
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
+import { generatePredefinedGroups } from '../utils/generate-predefined-groups'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { doesCustomGroupMatch } from '../utils/does-custom-group-match'
+import { singleCustomGroupJsonSchema } from './sort-union-types/types'
 import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { createEslintRule } from '../utils/create-eslint-rule'
 import { reportAllErrors } from '../utils/report-all-errors'
 import { shouldPartition } from '../utils/should-partition'
+import { allSelectors } from './sort-union-types/types'
 import { computeGroup } from '../utils/compute-group'
 import { rangeToDiff } from '../utils/range-to-diff'
 import { getSettings } from '../utils/get-settings'
 import { complete } from '../utils/complete'
 
-export type Options = [
-  Partial<
-    {
-      partitionByComment: PartitionByCommentOption
-      newlinesBetween: NewlinesBetweenOption
-      groups: GroupsOptions<Group>
-      partitionByNewLine: boolean
-    } & CommonOptions
-  >,
-]
-
-type Group =
-  | 'intersection'
-  | 'conditional'
-  | 'function'
-  | 'operator'
-  | 'keyword'
-  | 'literal'
-  | 'nullish'
-  | 'unknown'
-  | 'import'
-  | 'object'
-  | 'named'
-  | 'tuple'
-  | 'union'
+/**
+ * Cache computed groups by modifiers and selectors for performance
+ */
+let cachedGroupsByModifiersAndSelectors = new Map<string, string[]>()
 
 type MESSAGE_ID =
   | 'missedSpacingBetweenUnionTypes'
@@ -78,6 +58,7 @@ let defaultOptions: Required<Options[0]> = {
   type: 'alphabetical',
   ignoreCase: true,
   locales: 'en-US',
+  customGroups: [],
   alphabet: '',
   order: 'asc',
   groups: [],
@@ -86,6 +67,9 @@ let defaultOptions: Required<Options[0]> = {
 export let jsonSchema: JSONSchema4 = {
   properties: {
     ...commonJsonSchemas,
+    customGroups: buildCustomGroupsArrayJsonSchema({
+      singleCustomGroupJsonSchema,
+    }),
     partitionByComment: partitionByCommentJsonSchema,
     partitionByNewLine: partitionByNewLineJsonSchema,
     newlinesBetween: newlinesBetweenJsonSchema,
@@ -152,25 +136,9 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
   let options = complete(context.options.at(0), settings, defaultOptions)
   validateCustomSortConfiguration(options)
   validateGeneratedGroupsConfiguration({
-    selectors: [
-      'intersection',
-      'conditional',
-      'function',
-      'operator',
-      'keyword',
-      'literal',
-      'nullish',
-      'import',
-      'object',
-      'named',
-      'tuple',
-      'union',
-    ],
-    options: {
-      ...options,
-      customGroups: [],
-    },
+    selectors: allSelectors,
     modifiers: [],
+    options,
   })
   validateNewlinesAndPartitionConfiguration(options)
 
@@ -182,34 +150,34 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
 
   let formattedMembers: SortingNode[][] = node.types.reduce(
     (accumulator: SortingNode[][], type) => {
-      let predefinedGroups: string[] = []
+      let selectors: Selector[] = []
 
       switch (type.type) {
         case 'TSTemplateLiteralType':
         case 'TSLiteralType':
-          predefinedGroups.push('literal')
+          selectors.push('literal')
           break
         case 'TSIndexedAccessType':
         case 'TSTypeReference':
         case 'TSQualifiedName':
         case 'TSArrayType':
         case 'TSInferType':
-          predefinedGroups.push('named')
+          selectors.push('named')
           break
         case 'TSIntersectionType':
-          predefinedGroups.push('intersection')
+          selectors.push('intersection')
           break
         case 'TSUndefinedKeyword':
         case 'TSNullKeyword':
         case 'TSVoidKeyword':
-          predefinedGroups.push('nullish')
+          selectors.push('nullish')
           break
         case 'TSConditionalType':
-          predefinedGroups.push('conditional')
+          selectors.push('conditional')
           break
         case 'TSConstructorType':
         case 'TSFunctionType':
-          predefinedGroups.push('function')
+          selectors.push('function')
           break
         case 'TSBooleanKeyword':
         case 'TSUnknownKeyword':
@@ -221,28 +189,42 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
         case 'TSNeverKeyword':
         case 'TSAnyKeyword':
         case 'TSThisType':
-          predefinedGroups.push('keyword')
+          selectors.push('keyword')
           break
         case 'TSTypeOperator':
         case 'TSTypeQuery':
-          predefinedGroups.push('operator')
+          selectors.push('operator')
           break
         case 'TSTypeLiteral':
         case 'TSMappedType':
-          predefinedGroups.push('object')
+          selectors.push('object')
           break
         case 'TSImportType':
-          predefinedGroups.push('import')
+          selectors.push('import')
           break
         case 'TSTupleType':
-          predefinedGroups.push('tuple')
+          selectors.push('tuple')
           break
         case 'TSUnionType':
-          predefinedGroups.push('union')
+          selectors.push('union')
           break
       }
 
+      let name = sourceCode.getText(type)
+
+      let predefinedGroups = generatePredefinedGroups({
+        cache: cachedGroupsByModifiersAndSelectors,
+        modifiers: [],
+        selectors,
+      })
       let group = computeGroup({
+        customGroupMatcher: customGroup =>
+          doesCustomGroupMatch({
+            elementName: name,
+            modifiers: [],
+            customGroup,
+            selectors,
+          }),
         predefinedGroups,
         options,
       })
@@ -252,9 +234,9 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
       let sortingNode: SortingNode = {
         isEslintDisabled: isNodeEslintDisabled(type, eslintDisabledLines),
         size: rangeToDiff(type, sourceCode),
-        name: sourceCode.getText(type),
         node: type,
         group,
+        name,
       }
 
       if (
@@ -281,7 +263,8 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
       ignoreEslintDisabledNodes: boolean,
     ): SortingNode[] =>
       sortNodesByGroups({
-        getOptionsByGroupNumber: () => ({ options }),
+        getOptionsByGroupNumber:
+          buildGetCustomGroupOverriddenOptionsFunction(options),
         ignoreEslintDisabledNodes,
         groups: options.groups,
         nodes,
diff --git a/rules/sort-union-types/types.ts b/rules/sort-union-types/types.ts
new file mode 100644
index 0000000..d0270c3
--- /dev/null
+++ b/rules/sort-union-types/types.ts
@@ -0,0 +1,93 @@
+import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
+
+import type {
+  PartitionByCommentOption,
+  NewlinesBetweenOption,
+  CustomGroupsOption,
+  CommonOptions,
+  GroupsOptions,
+  RegexOption,
+} from '../../types/common-options'
+
+import {
+  buildCustomGroupSelectorJsonSchema,
+  regexJsonSchema,
+} from '../../utils/common-json-schemas'
+
+export type Options = [
+  Partial<
+    {
+      customGroups: CustomGroupsOption<SingleCustomGroup>
+      partitionByComment: PartitionByCommentOption
+      newlinesBetween: NewlinesBetweenOption
+      groups: GroupsOptions<Group>
+      partitionByNewLine: boolean
+    } & CommonOptions
+  >,
+]
+
+export type Selector =
+  | IntersectionSelector
+  | ConditionalSelector
+  | FunctionSelector
+  | OperatorSelector
+  | KeywordSelector
+  | LiteralSelector
+  | NullishSelector
+  | ImportSelector
+  | ObjectSelector
+  | NamedSelector
+  | TupleSelector
+  | UnionSelector
+
+export type SingleCustomGroup = {
+  elementNamePattern?: RegexOption
+} & {
+  selector?: Selector
+}
+
+type IntersectionSelector = 'intersection'
+
+type Group = 'unknown' | Selector | string
+
+type ConditionalSelector = 'conditional'
+
+type FunctionSelector = 'function'
+
+type OperatorSelector = 'operator'
+
+type KeywordSelector = 'keyword'
+
+type LiteralSelector = 'literal'
+
+type NullishSelector = 'nullish'
+
+type ImportSelector = 'import'
+
+type ObjectSelector = 'object'
+
+type NamedSelector = 'named'
+
+type TupleSelector = 'tuple'
+
+type UnionSelector = 'union'
+
+export let allSelectors: Selector[] = [
+  'intersection',
+  'conditional',
+  'function',
+  'operator',
+  'keyword',
+  'literal',
+  'nullish',
+  'import',
+  'object',
+  'named',
+  'tuple',
+  'union',
+]
+
+export let singleCustomGroupJsonSchema: Record<string, JSONSchema4> = {
+  selector: buildCustomGroupSelectorJsonSchema(allSelectors),
+  elementNamePattern: regexJsonSchema,
+}
diff --git a/utils/compute-group.ts b/utils/compute-group.ts
index 5e94a6f..9d9f79e 100644
--- a/utils/compute-group.ts
+++ b/utils/compute-group.ts
@@ -1,8 +1,8 @@
 import type {
   DeprecatedCustomGroupsOption,
-  NewlinesBetweenOption,
   CustomGroupsOption,
   AnyOfCustomGroup,
+  GroupsOptions,
 } from '../types/common-options'
 
 import { matches } from './matches'
@@ -12,7 +12,7 @@ interface GetGroupParameters<SingleCustomGroup> {
     customGroups?:
       | CustomGroupsOption<SingleCustomGroup>
       | DeprecatedCustomGroupsOption
-    groups: ({ newlinesBetween: NewlinesBetweenOption } | string[] | string)[]
+    groups: GroupsOptions<string>
   }
   customGroupMatcher?(
     customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup,
diff --git a/utils/get-group-number.ts b/utils/get-group-number.ts
index 8082d19..7dccc1f 100644
--- a/utils/get-group-number.ts
+++ b/utils/get-group-number.ts
@@ -1,7 +1,7 @@
-import type { NewlinesBetweenOption } from '../types/common-options'
+import type { GroupsOptions } from '../types/common-options'
 import type { SortingNode } from '../types/sorting-node'
 
-type Group = { newlinesBetween: NewlinesBetweenOption } | string[] | string
+type Group = GroupsOptions<string>[number]
 
 export let getGroupNumber = (groups: Group[], node: SortingNode): number => {
   for (let max = groups.length, i = 0; i < max; i++) {
diff --git a/utils/is-sortable.ts b/utils/is-sortable.ts
index 5dbce2d..db4ebfe 100644
--- a/utils/is-sortable.ts
+++ b/utils/is-sortable.ts
@@ -1,2 +1,2 @@
-export let isSortable = (node: unknown): boolean =>
+export let isSortable = (node: unknown): node is unknown[] =>
   Array.isArray(node) && node.length > 1


Test patch:
diff --git a/test/rules/sort-intersection-types.test.ts b/test/rules/sort-intersection-types.test.ts
index 8ed29df..2013984 100644
--- a/test/rules/sort-intersection-types.test.ts
+++ b/test/rules/sort-intersection-types.test.ts
@@ -1462,6 +1462,457 @@ describe(ruleName, () => {
         valid: [],
       },
     )
+
+    describe(`${ruleName}: custom groups`, () => {
+      ruleTester.run(`${ruleName}: filters on selector`, rule, {
+        invalid: [
+          {
+            errors: [
+              {
+                data: {
+                  rightGroup: 'namedElements',
+                  leftGroup: 'unknown',
+                  left: 'null',
+                  right: 'a',
+                },
+                messageId: 'unexpectedIntersectionTypesGroupOrder',
+              },
+            ],
+            options: [
+              {
+                customGroups: [
+                  {
+                    groupName: 'namedElements',
+                    selector: 'named',
+                  },
+                ],
+                groups: ['namedElements', 'unknown'],
+              },
+            ],
+            output: dedent`
+              type T =
+                & a
+                & null
+            `,
+            code: dedent`
+              type T =
+                & null
+                & a
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      for (let elementNamePattern of [
+        'hello',
+        ['noMatch', 'hello'],
+        { pattern: 'HELLO', flags: 'i' },
+        ['noMatch', { pattern: 'HELLO', flags: 'i' }],
+      ]) {
+        ruleTester.run(`${ruleName}: filters on elementNamePattern`, rule, {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'namedStartingWithHello',
+                      elementNamePattern,
+                      selector: 'named',
+                    },
+                  ],
+                  groups: ['namedStartingWithHello', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'namedStartingWithHello',
+                    leftGroup: 'unknown',
+                    right: 'helloNamed',
+                    left: 'undefined',
+                  },
+                  messageId: 'unexpectedIntersectionTypesGroupOrder',
+                },
+              ],
+              output: dedent`
+                type Type =
+                  & helloNamed
+                  & null
+                  & undefined
+              `,
+              code: dedent`
+                type Type =
+                  & null
+                  & undefined
+                  & helloNamed
+              `,
+            },
+          ],
+          valid: [],
+        })
+      }
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'type' and 'order'`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'bb',
+                    left: 'a',
+                  },
+                  messageId: 'unexpectedIntersectionTypesOrder',
+                },
+                {
+                  data: {
+                    right: 'ccc',
+                    left: 'bb',
+                  },
+                  messageId: 'unexpectedIntersectionTypesOrder',
+                },
+                {
+                  data: {
+                    right: 'dddd',
+                    left: 'ccc',
+                  },
+                  messageId: 'unexpectedIntersectionTypesOrder',
+                },
+                {
+                  data: {
+                    rightGroup: 'reversedNamedByLineLength',
+                    leftGroup: 'unknown',
+                    right: 'eee',
+                    left: "'m'",
+                  },
+                  messageId: 'unexpectedIntersectionTypesGroupOrder',
+                },
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'reversedNamedByLineLength',
+                      type: 'line-length',
+                      selector: 'named',
+                      order: 'desc',
+                    },
+                  ],
+                  groups: ['reversedNamedByLineLength', 'unknown'],
+                  type: 'alphabetical',
+                  order: 'asc',
+                },
+              ],
+              output: dedent`
+                type T =
+                  & dddd
+                  & ccc
+                  & eee
+                  & bb
+                  & ff
+                  & a
+                  & g
+                  & 'm'
+                  & 'o'
+                  & 'p'
+              `,
+              code: dedent`
+                type T =
+                  & a
+                  & bb
+                  & ccc
+                  & dddd
+                  & 'm'
+                  & eee
+                  & ff
+                  & g
+                  & 'o'
+                  & 'p'
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'fallbackSort'`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      fallbackSort: {
+                        type: 'alphabetical',
+                        order: 'asc',
+                      },
+                      elementNamePattern: '^foo',
+                      type: 'line-length',
+                      groupName: 'foo',
+                      order: 'desc',
+                    },
+                  ],
+                  type: 'alphabetical',
+                  groups: ['foo'],
+                  order: 'asc',
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    right: 'fooBar',
+                    left: 'fooZar',
+                  },
+                  messageId: 'unexpectedIntersectionTypesOrder',
+                },
+              ],
+              output: dedent`
+                type T =
+                  & fooBar
+                  & fooZar
+              `,
+              code: dedent`
+                type T =
+                  & fooZar
+                  & fooBar
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: does not sort custom groups with 'unsorted' type`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'unsortedNamed',
+                      selector: 'named',
+                      type: 'unsorted',
+                    },
+                  ],
+                  groups: ['unsortedNamed', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'unsortedNamed',
+                    leftGroup: 'unknown',
+                    left: "'m'",
+                    right: 'c',
+                  },
+                  messageId: 'unexpectedIntersectionTypesGroupOrder',
+                },
+              ],
+              output: dedent`
+                type T =
+                  & b
+                  & a
+                  & d
+                  & e
+                  & c
+                  & 'm'
+              `,
+              code: dedent`
+                type T =
+                  & b
+                  & a
+                  & d
+                  & e
+                  & 'm'
+                  & c
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: sort custom group blocks`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    anyOf: [
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        selector: 'named',
+                      },
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        selector: 'literal',
+                      },
+                    ],
+                    groupName: 'elementsIncludingFoo',
+                  },
+                ],
+                groups: ['elementsIncludingFoo', 'unknown'],
+                newlinesBetween: 'always',
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  rightGroup: 'elementsIncludingFoo',
+                  leftGroup: 'unknown',
+                  right: "'foo'",
+                  left: 'null',
+                },
+                messageId: 'unexpectedIntersectionTypesGroupOrder',
+              },
+            ],
+            output: dedent`
+              type T =
+                & 'foo'
+                & cFoo
+
+                & null
+            `,
+            code: dedent`
+              type T =
+                & null
+                & 'foo'
+                & cFoo
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      ruleTester.run(
+        `${ruleName}: allows to use regex for element names in custom groups`,
+        rule,
+        {
+          valid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      elementNamePattern: '^(?!.*Foo).*$',
+                      groupName: 'elementsWithoutFoo',
+                    },
+                  ],
+                  groups: ['unknown', 'elementsWithoutFoo'],
+                  type: 'alphabetical',
+                },
+              ],
+              code: dedent`
+                type T =
+                  iHaveFooInMyName
+                  meTooIHaveFoo
+                  a
+                  b
+              `,
+            },
+          ],
+          invalid: [],
+        },
+      )
+    })
+
+    describe('newlinesInside', () => {
+      ruleTester.run(
+        `${ruleName}: allows to use newlinesInside: always`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      newlinesInside: 'always',
+                      groupName: 'group1',
+                      selector: 'named',
+                    },
+                  ],
+                  groups: ['group1'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    right: 'b',
+                    left: 'a',
+                  },
+                  messageId: 'missedSpacingBetweenIntersectionTypes',
+                },
+              ],
+              output: dedent`
+                type T =
+                  & a
+
+                  & b
+              `,
+              code: dedent`
+                type T =
+                  & a
+                  & b
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: allows to use newlinesInside: never`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    newlinesInside: 'never',
+                    groupName: 'group1',
+                    selector: 'named',
+                  },
+                ],
+                type: 'alphabetical',
+                groups: ['group1'],
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  right: 'b',
+                  left: 'a',
+                },
+                messageId: 'extraSpacingBetweenIntersectionTypes',
+              },
+            ],
+            output: dedent`
+              type T =
+                & a
+                & b
+            `,
+            code: dedent`
+              type T =
+                & a
+
+                & b
+            `,
+          },
+        ],
+        valid: [],
+      })
+    })
   })
 
   describe(`${ruleName}: sorting by natural order`, () => {
diff --git a/test/rules/sort-union-types.test.ts b/test/rules/sort-union-types.test.ts
index 20649c6..87978b0 100644
--- a/test/rules/sort-union-types.test.ts
+++ b/test/rules/sort-union-types.test.ts
@@ -1465,6 +1465,457 @@ describe(ruleName, () => {
         valid: [],
       },
     )
+
+    describe(`${ruleName}: custom groups`, () => {
+      ruleTester.run(`${ruleName}: filters on selector`, rule, {
+        invalid: [
+          {
+            errors: [
+              {
+                data: {
+                  rightGroup: 'namedElements',
+                  leftGroup: 'unknown',
+                  left: 'null',
+                  right: 'a',
+                },
+                messageId: 'unexpectedUnionTypesGroupOrder',
+              },
+            ],
+            options: [
+              {
+                customGroups: [
+                  {
+                    groupName: 'namedElements',
+                    selector: 'named',
+                  },
+                ],
+                groups: ['namedElements', 'unknown'],
+              },
+            ],
+            output: dedent`
+              type T =
+                | a
+                | null
+            `,
+            code: dedent`
+              type T =
+                | null
+                | a
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      for (let elementNamePattern of [
+        'hello',
+        ['noMatch', 'hello'],
+        { pattern: 'HELLO', flags: 'i' },
+        ['noMatch', { pattern: 'HELLO', flags: 'i' }],
+      ]) {
+        ruleTester.run(`${ruleName}: filters on elementNamePattern`, rule, {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'namedStartingWithHello',
+                      elementNamePattern,
+                      selector: 'named',
+                    },
+                  ],
+                  groups: ['namedStartingWithHello', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'namedStartingWithHello',
+                    leftGroup: 'unknown',
+                    right: 'helloNamed',
+                    left: 'undefined',
+                  },
+                  messageId: 'unexpectedUnionTypesGroupOrder',
+                },
+              ],
+              output: dedent`
+                type Type =
+                  | helloNamed
+                  | null
+                  | undefined
+              `,
+              code: dedent`
+                type Type =
+                  | null
+                  | undefined
+                  | helloNamed
+              `,
+            },
+          ],
+          valid: [],
+        })
+      }
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'type' and 'order'`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'bb',
+                    left: 'a',
+                  },
+                  messageId: 'unexpectedUnionTypesOrder',
+                },
+                {
+                  data: {
+                    right: 'ccc',
+                    left: 'bb',
+                  },
+                  messageId: 'unexpectedUnionTypesOrder',
+                },
+                {
+                  data: {
+                    right: 'dddd',
+                    left: 'ccc',
+                  },
+                  messageId: 'unexpectedUnionTypesOrder',
+                },
+                {
+                  data: {
+                    rightGroup: 'reversedNamedByLineLength',
+                    leftGroup: 'unknown',
+                    right: 'eee',
+                    left: "'m'",
+                  },
+                  messageId: 'unexpectedUnionTypesGroupOrder',
+                },
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'reversedNamedByLineLength',
+                      type: 'line-length',
+                      selector: 'named',
+                      order: 'desc',
+                    },
+                  ],
+                  groups: ['reversedNamedByLineLength', 'unknown'],
+                  type: 'alphabetical',
+                  order: 'asc',
+                },
+              ],
+              output: dedent`
+                type T =
+                  | dddd
+                  | ccc
+                  | eee
+                  | bb
+                  | ff
+                  | a
+                  | g
+                  | 'm'
+                  | 'o'
+                  | 'p'
+              `,
+              code: dedent`
+                type T =
+                  | a
+                  | bb
+                  | ccc
+                  | dddd
+                  | 'm'
+                  | eee
+                  | ff
+                  | g
+                  | 'o'
+                  | 'p'
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'fallbackSort'`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      fallbackSort: {
+                        type: 'alphabetical',
+                        order: 'asc',
+                      },
+                      elementNamePattern: '^foo',
+                      type: 'line-length',
+                      groupName: 'foo',
+                      order: 'desc',
+                    },
+                  ],
+                  type: 'alphabetical',
+                  groups: ['foo'],
+                  order: 'asc',
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    right: 'fooBar',
+                    left: 'fooZar',
+                  },
+                  messageId: 'unexpectedUnionTypesOrder',
+                },
+              ],
+              output: dedent`
+                type T =
+                  | fooBar
+                  | fooZar
+              `,
+              code: dedent`
+                type T =
+                  | fooZar
+                  | fooBar
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: does not sort custom groups with 'unsorted' type`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'unsortedNamed',
+                      selector: 'named',
+                      type: 'unsorted',
+                    },
+                  ],
+                  groups: ['unsortedNamed', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'unsortedNamed',
+                    leftGroup: 'unknown',
+                    left: "'m'",
+                    right: 'c',
+                  },
+                  messageId: 'unexpectedUnionTypesGroupOrder',
+                },
+              ],
+              output: dedent`
+                type T =
+                  | b
+                  | a
+                  | d
+                  | e
+                  | c
+                  | 'm'
+              `,
+              code: dedent`
+                type T =
+                  | b
+                  | a
+                  | d
+                  | e
+                  | 'm'
+                  | c
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: sort custom group blocks`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    anyOf: [
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        selector: 'named',
+                      },
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        selector: 'literal',
+                      },
+                    ],
+                    groupName: 'elementsIncludingFoo',
+                  },
+                ],
+                groups: ['elementsIncludingFoo', 'unknown'],
+                newlinesBetween: 'always',
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  rightGroup: 'elementsIncludingFoo',
+                  leftGroup: 'unknown',
+                  right: "'foo'",
+                  left: 'null',
+                },
+                messageId: 'unexpectedUnionTypesGroupOrder',
+              },
+            ],
+            output: dedent`
+              type T =
+                | 'foo'
+                | cFoo
+
+                | null
+            `,
+            code: dedent`
+              type T =
+                | null
+                | 'foo'
+                | cFoo
+            `,
+          },
+        ],
+        valid: [],
+      })
+
+      ruleTester.run(
+        `${ruleName}: allows to use regex for element names in custom groups`,
+        rule,
+        {
+          valid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      elementNamePattern: '^(?!.*Foo).*$',
+                      groupName: 'elementsWithoutFoo',
+                    },
+                  ],
+                  groups: ['unknown', 'elementsWithoutFoo'],
+                  type: 'alphabetical',
+                },
+              ],
+              code: dedent`
+                type T =
+                  iHaveFooInMyName
+                  meTooIHaveFoo
+                  a
+                  b
+              `,
+            },
+          ],
+          invalid: [],
+        },
+      )
+    })
+
+    describe('newlinesInside', () => {
+      ruleTester.run(
+        `${ruleName}: allows to use newlinesInside: always`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      newlinesInside: 'always',
+                      groupName: 'group1',
+                      selector: 'named',
+                    },
+                  ],
+                  groups: ['group1'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    right: 'b',
+                    left: 'a',
+                  },
+                  messageId: 'missedSpacingBetweenUnionTypes',
+                },
+              ],
+              output: dedent`
+                type T =
+                  | a
+
+                  | b
+              `,
+              code: dedent`
+                type T =
+                  | a
+                  | b
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: allows to use newlinesInside: never`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    newlinesInside: 'never',
+                    groupName: 'group1',
+                    selector: 'named',
+                  },
+                ],
+                type: 'alphabetical',
+                groups: ['group1'],
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  right: 'b',
+                  left: 'a',
+                },
+                messageId: 'extraSpacingBetweenUnionTypes',
+              },
+            ],
+            output: dedent`
+              type T =
+                | a
+                | b
+            `,
+            code: dedent`
+              type T =
+                | a
+
+                | b
+            `,
+          },
+        ],
+        valid: [],
+      })
+    })
   })
 
   describe(`${ruleName}: sorting by natural order`, () => {