Repository: azat-io/eslint-plugin-perfectionist
Pull request #482 (2025-03-08 20:57:58)

feat(sort-exports): add `groups`-related options
- Request from https://github.com/azat-io/eslint-plugin-perfectionist/issues/479#issue-2898499262

> PS: We would love to see groups extended to sort-exports as well, especially once this feature is supported 👍

## Description

This PR adds the following options to `sort-exports`:
- `groups`
- `customGroups` (new API)
- `newlinesBetween`

It also deprecates `groupKind` for `groups`.

### `groups`

- Allowed selectors: `export`.
- Allowed modifiers: `value`, `type`.

Example:

- `value-export`.
- `type-export`.

### `customGroups`

The `customGroups` option supports `elementNamePattern`.

## 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/482?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 [(`db30f86`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/db30f861c6ff8a35cb7382d3e12733bb42f23536?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.) to head [(`417df1c`)](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/commit/417df1cbfece62c7264c5851fcc3f39e5e9a2d69?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Azat+S.).

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


```diff
@@            Coverage Diff            @@
##              main      #482   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           95        95           
  Lines         7637      7661   +24     
  Branches      1633      1636    +3     
=========================================
+ Hits          7637      7661   +24     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/azat-io/eslint-plugin-perfectionist/pull/482?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>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [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>
nwidynski: @hugop95 Thank you so much for working on this 🚀 It looks great so far!

Patch:
diff --git a/docs/content/rules/sort-exports.mdx b/docs/content/rules/sort-exports.mdx
index 1dc19bd..1612b52 100644
--- a/docs/content/rules/sort-exports.mdx
+++ b/docs/content/rules/sort-exports.mdx
@@ -88,7 +88,7 @@ Specifies the sorting method.
 - `'natural'` — Sort items in a [natural](https://github.com/yobacca/natural-orderby) order (e.g., “item2” < “item10”).
 - `'line-length'` — Sort items by the length of the code line (shorter lines first).
 - `'custom'` — Sort items using the alphabet entered in the [`alphabet`](#alphabet) option.
-- `'unsorted'` — Do not sort items.
+- `'unsorted'` — Do not sort items. [`grouping`](#groups) and [`newlines behavior`](#newlinesbetween) are still enforced.
 
 ### order
 
@@ -193,16 +193,147 @@ export { AnotherNamed } from './second-folder';
 
 Each group of exports (separated by empty lines) is treated independently, and the order within each group is preserved.
 
-### groupKind
+### newlinesBetween
+
+<sub>default: `'ignore'`</sub>
+
+Specifies how new lines should be handled between groups.
+
+- `ignore` — Do not report errors related to new lines.
+- `always` — Enforce one new line between each group, and forbid new lines inside a group.
+- `never` — No new lines are allowed.
+
+You can also enforce the newline behavior between two specific groups through the `groups` options.
+
+See the [`groups`](#newlines-between-groups) option.
+
+This option is only applicable when `partitionByNewLine` is `false`.
+
+### [DEPRECATED] groupKind
 
 <sub>default: `'mixed'`</sub>
 
+Use the [groups](#groups) option with the `value` and `type` modifiers instead.
+
 Allows you to group exports by their kind, determining whether value exports should come before or after type exports.
 
 - `mixed` — Do not group named exports by their kind; export statements are sorted together regardless of their type.
 - `values-first` — Group all value exports before type exports.
 - `types-first` — Group all type exports before value exports.
 
+### groups
+
+<sub>
+  type: `Array<string | string[]>`
+</sub>
+<sub>default: `[]`</sub>
+
+Allows you to specify a list of export groups for sorting. Groups help organize exports into categories, making them more readable and maintainable.
+
+Each export 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 and potentially multiple modifiers. You may enter modifiers in any order, but the selector must always come at the end.
+
+#### Selectors
+
+The only selector possible for this rule is `export`.
+
+#### Modifiers
+
+- `value`: Matches value exports.
+- `type`: Matches type exports.
+
+Example: `type-export`.
+
+#### Important notes
+
+##### The `unknown` 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.
+
+##### Newlines between groups
+
+You may place `newlinesBetween` objects between your groups to enforce the newline behavior between two specific groups.
+
+See the [`newlinesBetween`](#newlinesbetween) option.
+
+This feature is only applicable when `partitionByNewLine` is false.
+
+```ts
+{
+  newlinesBetween: 'always',
+  groups: [
+    'a',
+    { newlinesBetween: 'never' }, // Overrides the global newlinesBetween option
+    'b',
+  ]
+}
+```
+
+### customGroups
+
+<sub>
+  type: `Array<CustomGroupDefinition | CustomGroupAnyOfDefinition>`
+</sub>
+<sub>default: `{}`</sub>
+
+You can define your own groups and use regexp patterns to match specific exports.
+
+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' }
+  selector?: string
+  elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+}
+
+```
+An export 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' }
+  anyOf: Array<{
+      selector?: string
+      elementNamePattern?: string | string[] | { pattern: string; flags?: string } | { pattern: string; flags?: string }[]
+  }>
+}
+```
+
+An export 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` 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 sort type for that custom group. `unsorted` will not sort the group.
+- `order`: Overrides the sort order for that custom group
+- `fallbackSort`: Overrides the fallbackSort option for that custom 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
@@ -228,7 +359,10 @@ Allows you to group exports by their kind, determining whether value exports sho
                   specialCharacters: 'keep',
                   partitionByComment: false,
                   partitionByNewLine: false,
+                  newlinesBetween: 'ignore',
                   groupKind: 'mixed',
+                  groups: [],
+                  customGroups: [],
                 },
               ],
             },
@@ -256,7 +390,10 @@ Allows you to group exports by their kind, determining whether value exports sho
                 specialCharacters: 'keep',
                 partitionByComment: false,
                 partitionByNewLine: false,
+                newlinesBetween: 'ignore',
                 groupKind: 'mixed',
+                groups: [],
+                customGroups: [],
               },
             ],
           },
diff --git a/docs/content/rules/sort-sets.mdx b/docs/content/rules/sort-sets.mdx
index 83d6d39..5572a08 100644
--- a/docs/content/rules/sort-sets.mdx
+++ b/docs/content/rules/sort-sets.mdx
@@ -327,7 +327,7 @@ This feature is only applicable when `partitionByNewLine` is false.
 </sub>
 <sub>default: `{}`</sub>
 
-You can define your own groups and use regexp patterns to match specific object type members.
+You can define your own groups and use regexp patterns to match specific elements.
 
 A custom group definition may follow one of the two following interfaces:
 
diff --git a/rules/sort-array-includes.ts b/rules/sort-array-includes.ts
index bd59a26..4fea1e1 100644
--- a/rules/sort-array-includes.ts
+++ b/rules/sort-array-includes.ts
@@ -82,8 +82,8 @@ export let jsonSchema: JSONSchema4 = {
     properties: {
       ...commonJsonSchemas,
       groupKind: {
+        description: '[DEPRECATED] Specifies top-level groups.',
         enum: ['mixed', 'literals-first', 'spreads-first'],
-        description: 'Specifies top-level groups.',
         type: 'string',
       },
       customGroups: buildCustomGroupsArrayJsonSchema({
diff --git a/rules/sort-exports.ts b/rules/sort-exports.ts
index 6f13b74..29e740c 100644
--- a/rules/sort-exports.ts
+++ b/rules/sort-exports.ts
@@ -1,37 +1,45 @@
 import type { TSESTree } from '@typescript-eslint/types'
 
-import type {
-  PartitionByCommentOption,
-  CommonOptions,
-} from '../types/common-options'
+import type { Modifier, Selector, Options } from './sort-exports/types'
 import type { SortingNode } from '../types/sorting-node'
 
 import {
+  buildCustomGroupsArrayJsonSchema,
   partitionByCommentJsonSchema,
   partitionByNewLineJsonSchema,
+  newlinesBetweenJsonSchema,
   commonJsonSchemas,
+  groupsJsonSchema,
 } from '../utils/common-json-schemas'
+import {
+  MISSED_SPACING_ERROR,
+  EXTRA_SPACING_ERROR,
+  GROUP_ORDER_ERROR,
+  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 { doesCustomGroupMatch } from './sort-exports/does-custom-group-match'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
+import { singleCustomGroupJsonSchema } from './sort-exports/types'
+import { allModifiers, allSelectors } from './sort-exports/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 { rangeToDiff } from '../utils/range-to-diff'
-import { ORDER_ERROR } from '../utils/report-errors'
 import { getSettings } from '../utils/get-settings'
-import { sortNodes } from '../utils/sort-nodes'
+import { useGroups } from '../utils/use-groups'
 import { complete } from '../utils/complete'
 
-type Options = [
-  Partial<
-    {
-      groupKind: 'values-first' | 'types-first' | 'mixed'
-      partitionByComment: PartitionByCommentOption
-      partitionByNewLine: boolean
-    } & CommonOptions
-  >,
-]
+/**
+ * Cache computed groups by modifiers and selectors for performance
+ */
+let cachedGroupsByModifiersAndSelectors = new Map<string, string[]>()
 
 interface SortExportsSortingNode
   extends SortingNode<
@@ -40,19 +48,26 @@ interface SortExportsSortingNode
   groupKind: 'value' | 'type'
 }
 
-type MESSAGE_ID = 'unexpectedExportsOrder'
+type MESSAGE_ID =
+  | 'unexpectedExportsGroupOrder'
+  | 'missedSpacingBetweenExports'
+  | 'extraSpacingBetweenExports'
+  | 'unexpectedExportsOrder'
 
 let defaultOptions: Required<Options[0]> = {
   fallbackSort: { type: 'unsorted' },
   specialCharacters: 'keep',
   partitionByComment: false,
+  newlinesBetween: 'ignore',
   partitionByNewLine: false,
   type: 'alphabetical',
   groupKind: 'mixed',
+  customGroups: [],
   ignoreCase: true,
   locales: 'en-US',
   alphabet: '',
   order: 'asc',
+  groups: [],
 }
 
 export default createEslintRule<Options, MESSAGE_ID>({
@@ -61,6 +76,12 @@ export default createEslintRule<Options, MESSAGE_ID>({
 
     let options = complete(context.options.at(0), settings, defaultOptions)
     validateCustomSortConfiguration(options)
+    validateGeneratedGroupsConfiguration({
+      modifiers: allModifiers,
+      selectors: allSelectors,
+      options,
+    })
+    validateNewlinesAndPartitionConfiguration(options)
 
     let { sourceCode, id } = context
     let eslintDisabledLines = getEslintDisabledLines({
@@ -68,22 +89,62 @@ export default createEslintRule<Options, MESSAGE_ID>({
       sourceCode,
     })
 
-    let parts: SortExportsSortingNode[][] = [[]]
+    let formattedMembers: SortExportsSortingNode[][] = [[]]
 
     let registerNode = (
       node:
         | TSESTree.ExportNamedDeclarationWithSource
         | TSESTree.ExportAllDeclaration,
     ): void => {
+      let { defineGroup, getGroup } = useGroups(options)
+      let selector: Selector = 'export'
+      let modifiers: Modifier[] = []
+      if (node.exportKind === 'value') {
+        modifiers.push('value')
+      } else {
+        modifiers.push('type')
+      }
+
+      let predefinedGroups = generatePredefinedGroups({
+        cache: cachedGroupsByModifiersAndSelectors,
+        selectors: [selector],
+        modifiers,
+      })
+
+      for (let predefinedGroup of predefinedGroups) {
+        defineGroup(predefinedGroup)
+      }
+
+      let name = node.source.value
+      for (let customGroup of options.customGroups) {
+        if (
+          doesCustomGroupMatch({
+            elementName: name,
+            customGroup,
+            modifiers,
+          })
+        ) {
+          defineGroup(customGroup.groupName, true)
+          /**
+           * If the custom group is not referenced in the `groups` option, it
+           * will be ignored
+           */
+          if (getGroup() === customGroup.groupName) {
+            break
+          }
+        }
+      }
+
       let sortingNode: SortExportsSortingNode = {
         isEslintDisabled: isNodeEslintDisabled(node, eslintDisabledLines),
         groupKind: node.exportKind === 'value' ? 'value' : 'type',
         size: rangeToDiff(node, sourceCode),
         addSafetySemicolonWhenInline: true,
-        name: node.source.value,
+        group: getGroup(),
+        name,
         node,
       }
-      let lastNode = parts.at(-1)?.at(-1)
+      let lastNode = formattedMembers.at(-1)?.at(-1)
 
       if (
         shouldPartition({
@@ -93,10 +154,10 @@ export default createEslintRule<Options, MESSAGE_ID>({
           options,
         })
       ) {
-        parts.push([])
+        formattedMembers.push([])
       }
 
-      parts.at(-1)!.push(sortingNode)
+      formattedMembers.at(-1)!.push(sortingNode)
     }
 
     return {
@@ -110,7 +171,7 @@ export default createEslintRule<Options, MESSAGE_ID>({
           groupKindOrder = ['any'] as const
         }
 
-        for (let nodes of parts) {
+        for (let nodes of formattedMembers) {
           let filteredGroupKindNodes = groupKindOrder.map(groupKind =>
             nodes.filter(
               currentNode =>
@@ -121,15 +182,20 @@ export default createEslintRule<Options, MESSAGE_ID>({
             ignoreEslintDisabledNodes: boolean,
           ): SortExportsSortingNode[] =>
             filteredGroupKindNodes.flatMap(groupedNodes =>
-              sortNodes({
+              sortNodesByGroups({
+                getOptionsByGroupNumber:
+                  buildGetCustomGroupOverriddenOptionsFunction(options),
                 ignoreEslintDisabledNodes,
+                groups: options.groups,
                 nodes: groupedNodes,
-                options,
               }),
             )
 
           reportAllErrors<MESSAGE_ID>({
             availableMessageIds: {
+              missedSpacingBetweenMembers: 'missedSpacingBetweenExports',
+              extraSpacingBetweenMembers: 'extraSpacingBetweenExports',
+              unexpectedGroupOrder: 'unexpectedExportsGroupOrder',
               unexpectedOrder: 'unexpectedExportsOrder',
             },
             sortNodesExcludingEslintDisabled,
@@ -149,30 +215,40 @@ export default createEslintRule<Options, MESSAGE_ID>({
     }
   },
   meta: {
-    schema: [
-      {
+    schema: {
+      items: {
         properties: {
           ...commonJsonSchemas,
           groupKind: {
+            description: '[DEPRECATED] Specifies top-level groups.',
             enum: ['mixed', 'values-first', 'types-first'],
-            description: 'Specifies top-level groups.',
             type: 'string',
           },
+          customGroups: buildCustomGroupsArrayJsonSchema({
+            singleCustomGroupJsonSchema,
+          }),
           partitionByComment: partitionByCommentJsonSchema,
           partitionByNewLine: partitionByNewLineJsonSchema,
+          newlinesBetween: newlinesBetweenJsonSchema,
+          groups: groupsJsonSchema,
         },
         additionalProperties: false,
         type: 'object',
       },
-    ],
+      uniqueItems: true,
+      type: 'array',
+    },
+    messages: {
+      missedSpacingBetweenExports: MISSED_SPACING_ERROR,
+      extraSpacingBetweenExports: EXTRA_SPACING_ERROR,
+      unexpectedExportsGroupOrder: GROUP_ORDER_ERROR,
+      unexpectedExportsOrder: ORDER_ERROR,
+    },
     docs: {
       url: 'https://perfectionist.dev/rules/sort-exports',
       description: 'Enforce sorted exports.',
       recommended: true,
     },
-    messages: {
-      unexpectedExportsOrder: ORDER_ERROR,
-    },
     type: 'suggestion',
     fixable: 'code',
   },
diff --git a/rules/sort-exports/does-custom-group-match.ts b/rules/sort-exports/does-custom-group-match.ts
new file mode 100644
index 0000000..7e55cf1
--- /dev/null
+++ b/rules/sort-exports/does-custom-group-match.ts
@@ -0,0 +1,43 @@
+import type { AnyOfCustomGroup } from '../../types/common-options'
+import type { SingleCustomGroup, Modifier } from './types'
+
+import { matches } from '../../utils/matches'
+
+interface DoesCustomGroupMatchParameters {
+  customGroup: AnyOfCustomGroup<SingleCustomGroup> | SingleCustomGroup
+  modifiers: Modifier[]
+  elementName: string
+}
+
+export let doesCustomGroupMatch = (
+  props: DoesCustomGroupMatchParameters,
+): boolean => {
+  if ('anyOf' in props.customGroup) {
+    return props.customGroup.anyOf.some(subgroup =>
+      doesCustomGroupMatch({ ...props, customGroup: subgroup }),
+    )
+  }
+
+  if (props.customGroup.modifiers) {
+    for (let modifier of props.customGroup.modifiers) {
+      if (!props.modifiers.includes(modifier)) {
+        return false
+      }
+    }
+  }
+
+  if (
+    'elementNamePattern' in props.customGroup &&
+    props.customGroup.elementNamePattern
+  ) {
+    let matchesElementNamePattern: boolean = matches(
+      props.elementName,
+      props.customGroup.elementNamePattern,
+    )
+    if (!matchesElementNamePattern) {
+      return false
+    }
+  }
+
+  return true
+}
diff --git a/rules/sort-exports/types.ts b/rules/sort-exports/types.ts
new file mode 100644
index 0000000..4b08382
--- /dev/null
+++ b/rules/sort-exports/types.ts
@@ -0,0 +1,61 @@
+import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
+
+import type {
+  PartitionByCommentOption,
+  NewlinesBetweenOption,
+  CustomGroupsOption,
+  CommonOptions,
+  GroupsOptions,
+  RegexOption,
+} from '../../types/common-options'
+import type { JoinWithDash } from '../../types/join-with-dash'
+
+import {
+  buildCustomGroupModifiersJsonSchema,
+  buildCustomGroupSelectorJsonSchema,
+  regexJsonSchema,
+} from '../../utils/common-json-schemas'
+
+export type Options = Partial<
+  {
+    customGroups: CustomGroupsOption<SingleCustomGroup>
+    /**
+     * @deprecated for {@link `groups`}
+     */
+    groupKind: 'values-first' | 'types-first' | 'mixed'
+    partitionByComment: PartitionByCommentOption
+    newlinesBetween: NewlinesBetweenOption
+    groups: GroupsOptions<Group>
+    partitionByNewLine: boolean
+  } & CommonOptions
+>[]
+
+export type SingleCustomGroup = {
+  modifiers?: Modifier[]
+  selector?: Selector
+} & {
+  elementNamePattern?: RegexOption
+}
+
+export type Modifier = ValueModifier | TypeModifier
+
+export type Selector = ExportSelector
+
+type ExportGroup = JoinWithDash<[ValueModifier, TypeModifier, ExportSelector]>
+
+type Group = ExportGroup | 'unknown' | string
+
+type ExportSelector = 'export'
+
+type ValueModifier = 'value'
+
+type TypeModifier = 'type'
+
+export let allSelectors: Selector[] = ['export']
+export let allModifiers: Modifier[] = ['value', 'type']
+
+export let singleCustomGroupJsonSchema: Record<string, JSONSchema4> = {
+  modifiers: buildCustomGroupModifiersJsonSchema(allModifiers),
+  selector: buildCustomGroupSelectorJsonSchema(allSelectors),
+  elementNamePattern: regexJsonSchema,
+}
diff --git a/rules/sort-object-types.ts b/rules/sort-object-types.ts
index 6efac36..aac1370 100644
--- a/rules/sort-object-types.ts
+++ b/rules/sort-object-types.ts
@@ -102,16 +102,16 @@ export let jsonSchema: JSONSchema4 = {
           }),
         ],
       },
+      groupKind: {
+        description: '[DEPRECATED] Specifies top-level groups.',
+        enum: ['mixed', 'required-first', 'optional-first'],
+        type: 'string',
+      },
       useConfigurationIf: buildUseConfigurationIfJsonSchema({
         additionalProperties: {
           declarationMatchesPattern: regexJsonSchema,
         },
       }),
-      groupKind: {
-        enum: ['mixed', 'required-first', 'optional-first'],
-        description: 'Specifies top-level groups.',
-        type: 'string',
-      },
       partitionByComment: partitionByCommentJsonSchema,
       partitionByNewLine: partitionByNewLineJsonSchema,
       newlinesBetween: newlinesBetweenJsonSchema,
diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts
index 3eb6570..3fbb5be 100644
--- a/rules/sort-objects.ts
+++ b/rules/sort-objects.ts
@@ -493,7 +493,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
             },
           }),
           destructureOnly: {
-            description: 'Controls whether to sort only destructured objects.',
+            description:
+              '[DEPRECATED] Controls whether to sort only destructured objects.',
             type: 'boolean',
           },
           objectDeclarations: {


Test patch:
diff --git a/test/rules/sort-exports.test.ts b/test/rules/sort-exports.test.ts
index c723dae..0d63e8c 100644
--- a/test/rules/sort-exports.test.ts
+++ b/test/rules/sort-exports.test.ts
@@ -811,6 +811,785 @@ describe(ruleName, () => {
         valid: [],
       },
     )
+
+    ruleTester.run(
+      `${ruleName}(${type}): allows to use predefined groups`,
+      rule,
+      {
+        invalid: [
+          {
+            errors: [
+              {
+                data: {
+                  rightGroup: 'value-export',
+                  leftGroup: 'type-export',
+                  right: 'b',
+                  left: 'a',
+                },
+                messageId: 'unexpectedExportsGroupOrder',
+              },
+            ],
+            options: [
+              {
+                ...options,
+                groups: ['value-export', 'type-export'],
+              },
+            ],
+            output: dedent`
+              export { b } from 'b';
+              export type { a } from 'a';
+            `,
+            code: dedent`
+              export type { a } from 'a';
+              export { b } from 'b';
+            `,
+          },
+        ],
+        valid: [],
+      },
+    )
+
+    describe(`${ruleName}: custom groups`, () => {
+      ruleTester.run(`${ruleName}: filters on modifier`, rule, {
+        invalid: [
+          {
+            errors: [
+              {
+                data: {
+                  rightGroup: 'valueExports',
+                  leftGroup: 'unknown',
+                  right: 'b',
+                  left: 'a',
+                },
+                messageId: 'unexpectedExportsGroupOrder',
+              },
+            ],
+            options: [
+              {
+                customGroups: [
+                  {
+                    groupName: 'valueExports',
+                    modifiers: ['value'],
+                  },
+                ],
+                groups: ['valueExports', 'unknown'],
+              },
+            ],
+            output: dedent`
+              export { b } from 'b';
+              export type { a } from 'a';
+            `,
+            code: dedent`
+              export type { a } from 'a';
+              export { b } from 'b';
+            `,
+          },
+        ],
+        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: [
+                {
+                  ...options,
+                  customGroups: [
+                    {
+                      groupName: 'valuesStartingWithHello',
+                      modifiers: ['value'],
+                      elementNamePattern,
+                    },
+                  ],
+                  groups: ['valuesStartingWithHello', 'unknown'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'valuesStartingWithHello',
+                    right: 'helloExport',
+                    leftGroup: 'unknown',
+                    left: 'b',
+                  },
+                  messageId: 'unexpectedExportsGroupOrder',
+                },
+              ],
+              output: dedent`
+                export { helloExport } from 'helloExport';
+                export { a } from 'a';
+                export { b } from 'b';
+              `,
+              code: dedent`
+                export { a } from 'a';
+                export { b } from 'b';
+                export { helloExport } from 'helloExport';
+              `,
+            },
+          ],
+          valid: [],
+        })
+      }
+
+      ruleTester.run(
+        `${ruleName}: handles complex regex in elementNamePattern`,
+        rule,
+        {
+          valid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      elementNamePattern: '^(?!.*Foo).*$',
+                      groupName: 'elementsWithoutFoo',
+                    },
+                  ],
+                  groups: ['unknown', 'elementsWithoutFoo'],
+                  type: 'alphabetical',
+                },
+              ],
+              code: dedent`
+                export { iHaveFooInMyName } from 'iHaveFooInMyName';
+                export { meTooIHaveFoo } from 'meTooIHaveFoo';
+                export { a } from 'a';
+                export { b } from 'b';
+              `,
+            },
+          ],
+          invalid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: sort custom groups by overriding 'type' and 'order'`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'bb',
+                    left: 'a',
+                  },
+                  messageId: 'unexpectedExportsOrder',
+                },
+                {
+                  data: {
+                    right: 'ccc',
+                    left: 'bb',
+                  },
+                  messageId: 'unexpectedExportsOrder',
+                },
+                {
+                  data: {
+                    right: 'dddd',
+                    left: 'ccc',
+                  },
+                  messageId: 'unexpectedExportsOrder',
+                },
+                {
+                  data: {
+                    rightGroup: 'reversedValuesByLineLength',
+                    leftGroup: 'unknown',
+                    right: 'eee',
+                    left: 'm',
+                  },
+                  messageId: 'unexpectedExportsGroupOrder',
+                },
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'reversedValuesByLineLength',
+                      modifiers: ['value'],
+                      type: 'line-length',
+                      order: 'desc',
+                    },
+                  ],
+                  groups: ['reversedValuesByLineLength', 'unknown'],
+                  type: 'alphabetical',
+                  groupKind: 'mixed',
+                  order: 'asc',
+                },
+              ],
+              output: dedent`
+                export { dddd } from 'dddd';
+                export { ccc } from 'ccc';
+                export { eee } from 'eee';
+                export { bb } from 'bb';
+                export { ff } from 'ff';
+                export { a } from 'a';
+                export { g } from 'g';
+                export type { m } from 'm';
+                export type { o } from 'o';
+                export type { p } from 'p';
+              `,
+              code: dedent`
+                export { a } from 'a';
+                export { bb } from 'bb';
+                export { ccc } from 'ccc';
+                export { dddd } from 'dddd';
+                export type { m } from 'm';
+                export { eee } from 'eee';
+                export { ff } from 'ff';
+                export { g } from 'g';
+                export type { o } from 'o';
+                export type { p } from '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: 'unexpectedExportsOrder',
+                },
+              ],
+              output: dedent`
+                export { fooBar } from 'fooBar';
+                export { fooZar } from 'fooZar';
+              `,
+              code: dedent`
+                export { fooZar } from 'fooZar';
+                export { fooBar } from 'fooBar';
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}: does not sort custom groups with 'unsorted' type`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'unsortedValues',
+                      modifiers: ['value'],
+                      type: 'unsorted',
+                    },
+                  ],
+                  groups: ['unsortedValues', 'unknown'],
+                  groupKind: 'mixed',
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'unsortedValues',
+                    leftGroup: 'unknown',
+                    right: 'c',
+                    left: 'm',
+                  },
+                  messageId: 'unexpectedExportsGroupOrder',
+                },
+              ],
+              output: dedent`
+                export { b } from 'b';
+                export { a } from 'a';
+                export { d } from 'd';
+                export { e } from 'e';
+                export { c } from 'c';
+                export type { m } from 'm';
+              `,
+              code: dedent`
+                export { b } from 'b';
+                export { a } from 'a';
+                export { d } from 'd';
+                export { e } from 'e';
+                export type { m } from 'm';
+                export { c } from 'c';
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: sort custom group blocks`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    anyOf: [
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        modifiers: ['value'],
+                      },
+                      {
+                        elementNamePattern: 'foo|Foo',
+                        modifiers: ['type'],
+                      },
+                    ],
+                    groupName: 'elementsIncludingFoo',
+                  },
+                ],
+                groups: ['elementsIncludingFoo', 'unknown'],
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  rightGroup: 'elementsIncludingFoo',
+                  leftGroup: 'unknown',
+                  right: 'cFoo',
+                  left: 'a',
+                },
+                messageId: 'unexpectedExportsGroupOrder',
+              },
+            ],
+            output: dedent`
+              export { cFoo } from 'cFoo';
+              export type { foo } from 'foo';
+              export { a } from 'a';
+            `,
+            code: dedent`
+              export { a } from 'a';
+              export { cFoo } from 'cFoo';
+              export type { foo } from 'foo';
+            `,
+          },
+        ],
+        valid: [],
+      })
+    })
+
+    describe(`${ruleName}: newlinesBetween`, () => {
+      ruleTester.run(
+        `${ruleName}(${type}): removes newlines when never`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'y',
+                    left: 'a',
+                  },
+                  messageId: 'extraSpacingBetweenExports',
+                },
+                {
+                  data: {
+                    right: 'b',
+                    left: 'z',
+                  },
+                  messageId: 'unexpectedExportsOrder',
+                },
+                {
+                  data: {
+                    right: 'b',
+                    left: 'z',
+                  },
+                  messageId: 'extraSpacingBetweenExports',
+                },
+              ],
+              options: [
+                {
+                  ...options,
+                  customGroups: [
+                    {
+                      elementNamePattern: 'a',
+                      groupName: 'a',
+                    },
+                  ],
+                  groups: ['a', 'unknown'],
+                  newlinesBetween: 'never',
+                },
+              ],
+              code: dedent`
+                  export { a } from 'a'
+
+
+                 export { y } from 'y'
+                export { z } from 'z'
+
+                    export { b } from 'b'
+              `,
+              output: dedent`
+                  export { a } from 'a'
+                 export { b } from 'b'
+                export { y } from 'y'
+                    export { z } from 'z'
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(
+        `${ruleName}(${type}): keeps one newline when always`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'z',
+                    left: 'a',
+                  },
+                  messageId: 'extraSpacingBetweenExports',
+                },
+                {
+                  data: {
+                    right: 'y',
+                    left: 'z',
+                  },
+                  messageId: 'unexpectedExportsOrder',
+                },
+                {
+                  data: {
+                    right: 'b',
+                    left: 'y',
+                  },
+                  messageId: 'missedSpacingBetweenExports',
+                },
+              ],
+              options: [
+                {
+                  ...options,
+                  customGroups: [
+                    {
+                      elementNamePattern: 'a',
+                      groupName: 'a',
+                    },
+                    {
+                      elementNamePattern: 'b',
+                      groupName: 'b',
+                    },
+                  ],
+                  groups: ['a', 'unknown', 'b'],
+                  newlinesBetween: 'always',
+                },
+              ],
+              output: dedent`
+                  export { a } from 'a'
+
+                 export { y } from 'y'
+                export { z } from 'z'
+
+                    export { b } from 'b'
+              `,
+              code: dedent`
+                  export { a } from 'a'
+
+
+                 export { z } from 'z'
+                export { y } from 'y'
+                    export { b } from 'b'
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      describe(`${ruleName}(${type}): "newlinesBetween" inside groups`, () => {
+        ruleTester.run(
+          `${ruleName}(${type}): handles "newlinesBetween" between consecutive groups`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    ...options,
+                    groups: [
+                      'a',
+                      { newlinesBetween: 'always' },
+                      'b',
+                      { newlinesBetween: 'always' },
+                      'c',
+                      { newlinesBetween: 'never' },
+                      'd',
+                      { newlinesBetween: 'ignore' },
+                      'e',
+                    ],
+                    customGroups: [
+                      { elementNamePattern: 'a', groupName: 'a' },
+                      { elementNamePattern: 'b', groupName: 'b' },
+                      { elementNamePattern: 'c', groupName: 'c' },
+                      { elementNamePattern: 'd', groupName: 'd' },
+                      { elementNamePattern: 'e', groupName: 'e' },
+                    ],
+                    newlinesBetween: 'always',
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'b',
+                      left: 'a',
+                    },
+                    messageId: 'missedSpacingBetweenExports',
+                  },
+                  {
+                    data: {
+                      right: 'c',
+                      left: 'b',
+                    },
+                    messageId: 'extraSpacingBetweenExports',
+                  },
+                  {
+                    data: {
+                      right: 'd',
+                      left: 'c',
+                    },
+                    messageId: 'extraSpacingBetweenExports',
+                  },
+                ],
+                output: dedent`
+                  export { a } from 'a'
+
+                  export { b } from 'b'
+
+                  export { c } from 'c'
+                  export { d } from 'd'
+
+
+                  export { e } from 'e'
+                `,
+                code: dedent`
+                  export { a } from 'a'
+                  export { b } from 'b'
+
+
+                  export { c } from 'c'
+
+                  export { d } from 'd'
+
+
+                  export { e } from 'e'
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+
+        describe(`${ruleName}(${type}): "newlinesBetween" between non-consecutive groups`, () => {
+          for (let [globalNewlinesBetween, groupNewlinesBetween] of [
+            ['always', 'never'] as const,
+            ['always', 'ignore'] as const,
+            ['never', 'always'] as const,
+            ['ignore', 'always'] as const,
+          ]) {
+            ruleTester.run(
+              `${ruleName}(${type}): enforces a newline if the global option is "${globalNewlinesBetween}" and the group option is "${groupNewlinesBetween}"`,
+              rule,
+              {
+                invalid: [
+                  {
+                    options: [
+                      {
+                        ...options,
+                        customGroups: [
+                          { elementNamePattern: 'a', groupName: 'a' },
+                          { elementNamePattern: 'b', groupName: 'b' },
+                          { groupName: 'unusedGroup', elementNamePattern: 'X' },
+                        ],
+                        groups: [
+                          'a',
+                          'unusedGroup',
+                          { newlinesBetween: groupNewlinesBetween },
+                          'b',
+                        ],
+                        newlinesBetween: globalNewlinesBetween,
+                      },
+                    ],
+                    errors: [
+                      {
+                        data: {
+                          right: 'b',
+                          left: 'a',
+                        },
+                        messageId: 'missedSpacingBetweenExports',
+                      },
+                    ],
+                    output: dedent`
+                      export { a } from 'a'
+
+                      export { b } from 'b'
+                    `,
+                    code: dedent`
+                      export { a } from 'a'
+                      export { b } from 'b'
+                    `,
+                  },
+                ],
+                valid: [],
+              },
+            )
+          }
+
+          for (let [globalNewlinesBetween, groupNewlinesBetween] of [
+            ['ignore', 'never'] as const,
+            ['never', 'ignore'] as const,
+          ]) {
+            ruleTester.run(
+              `${ruleName}(${type}): does not enforce a newline if the global option is "${globalNewlinesBetween}" and the group option is "${groupNewlinesBetween}"`,
+              rule,
+              {
+                valid: [
+                  {
+                    options: [
+                      {
+                        ...options,
+                        customGroups: [
+                          { elementNamePattern: 'a', groupName: 'a' },
+                          { elementNamePattern: 'b', groupName: 'b' },
+                          { groupName: 'unusedGroup', elementNamePattern: 'X' },
+                        ],
+                        groups: [
+                          'a',
+                          'unusedGroup',
+                          { newlinesBetween: groupNewlinesBetween },
+                          'b',
+                        ],
+                        newlinesBetween: globalNewlinesBetween,
+                      },
+                    ],
+                    code: dedent`
+                      export { a } from 'a'
+
+                      export { b } from 'b'
+                    `,
+                  },
+                  {
+                    options: [
+                      {
+                        ...options,
+                        customGroups: [
+                          { elementNamePattern: 'a', groupName: 'a' },
+                          { elementNamePattern: 'b', groupName: 'b' },
+                          { groupName: 'unusedGroup', elementNamePattern: 'X' },
+                        ],
+                        groups: [
+                          'a',
+                          'unusedGroup',
+                          { newlinesBetween: groupNewlinesBetween },
+                          'b',
+                        ],
+                        newlinesBetween: globalNewlinesBetween,
+                      },
+                    ],
+                    code: dedent`
+                      export { a } from 'a'
+                      export { b } from 'b'
+                    `,
+                  },
+                ],
+                invalid: [],
+              },
+            )
+          }
+        })
+      })
+
+      ruleTester.run(
+        `${ruleName}(${type}): handles newlines and comment after fixes`,
+        rule,
+        {
+          invalid: [
+            {
+              output: [
+                dedent`
+                  export { a } from 'a' // Comment after
+                  export { b } from 'b'
+
+                  export { c } from 'c'
+                `,
+                dedent`
+                  export { a } from 'a' // Comment after
+
+                  export { b } from 'b'
+                  export { c } from 'c'
+                `,
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      elementNamePattern: 'b|c',
+                      groupName: 'b|c',
+                    },
+                  ],
+                  groups: ['unknown', 'b|c'],
+                  newlinesBetween: 'always',
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'unknown',
+                    leftGroup: 'b|c',
+                    right: 'a',
+                    left: 'b',
+                  },
+                  messageId: 'unexpectedExportsGroupOrder',
+                },
+              ],
+              code: dedent`
+                export { b } from 'b'
+                export { a } from 'a' // Comment after
+
+                export { c } from 'c'
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+    })
   })
 
   describe(`${ruleName}: sorting by natural order`, () => {
@@ -1400,6 +2179,49 @@ describe(ruleName, () => {
       ],
       invalid: [],
     })
+
+    ruleTester.run(`${ruleName}(${type}): enforces newlines between`, rule, {
+      invalid: [
+        {
+          options: [
+            {
+              ...options,
+              customGroups: [
+                {
+                  elementNamePattern: 'a',
+                  groupName: 'a',
+                },
+                {
+                  elementNamePattern: 'b',
+                  groupName: 'b',
+                },
+              ],
+              newlinesBetween: 'always',
+              groups: ['b', 'a'],
+            },
+          ],
+          errors: [
+            {
+              data: {
+                right: 'a',
+                left: 'b',
+              },
+              messageId: 'missedSpacingBetweenExports',
+            },
+          ],
+          output: dedent`
+            export { b } from 'b'
+
+            export { a } from 'a'
+          `,
+          code: dedent`
+            export { b } from 'b'
+            export { a } from 'a'
+          `,
+        },
+      ],
+      valid: [],
+    })
   })
 
   describe('misc', () => {