Skip to content

[FEATURE] Add typed ErrorGroup with split() and exhaustive match() #33

Description

@martyy-code

Problem It Solves

Today, when several independent failures occur in parallel (validation of a batch of records, fan-out HTTP calls, multi-step async workflows), @deessejs/errors offers no first-class way to:

  1. Aggregate them into a single, throwable error so the call site can observe all failures in one place.
  2. Deaggregate them later by type, in a type-safe way, without instanceof ladders.
  3. Compose the aggregation with .from(cause) cause chains.

The internal docs/internal/product/design-philosophy.md already calls out the gap under "Why not ExceptionGroup?" -- acknowledging the limitation while deferring implementation to v1. The docs/internal/product/README.md lists "Error groups / ExceptionGroup / Async patterns" as a v2 candidate. That time has come.

The motivation has sharpened considerably with the upcoming typed-args refactor proposed in #32. Once each error carries a zod-validated, inferred-type fields object, it becomes natural and clean to compose a z.array(z.union([E1, E2, E3])) describing a typed collection of errors. Without groups, the new typed-args system still forces users to either:

  • throw the first error and abandon the rest (information loss), or
  • manually accumulate and re-throw with custom conventions (no library support, no exhaustiveness, no JSON-friendly structure).

Real-world use cases

  • Batch validation. Validate N records; emit every ValidationError instead of stopping at the first.
  • Parallel HTTP fan-out. A Promise.allSettled-style workflow where multiple endpoints can fail independently. The user wants a single error containing every endpoint failure, classified by HTTP status / type.
  • Multi-step onboarding / form submission. Each step can fail for a different reason (EmailError, PaymentError, ProfileError). The user wants a single error surface, with the ability to inspect each branch.

Why now (and not v1)

  • The typed-args refactor ([Refactor]: Promote StandardSchemaV1 fields to runtime validation + message-as-function #32) unlocks z.array(z.union([...])) as a first-class input to error factories. Building groups on top of the template-string {placeholder} system would be a square peg in a round hole.
  • The function-based design philosophy is fully compatible: ErrorGroup can be defined as error({ name: 'ErrorGroup', inherits: [BaseError], args, message }) -- no class X extends Error required, no instanceof, no new.
  • The no-except* argument that justified the v1 deferral is still true on the JS side. We address it by providing a typed deaggregation helper (split(condition)) and a typed exhaustive matcher (group.match().with(...).exhaustive()) that delivers the same ergonomic value without requiring new syntax.
  • The previous caveat ("Promise.allSettled covers the common case") is a workaround, not a feature. Users still reinvent group handling on top of it. A library-level abstraction is the correct level of fix.

Proposed Solution

Add a new public API: ErrorGroup, defined as an error factory in the same way as every other error in the library. No classes, no extends, no instanceof. All operations are methods on the instance or helpers exported alongside it.

1. ErrorGroup factory

import { error, is } from '@deessejs/errors'
import { z } from 'zod'

const BaseError = error({ name: 'BaseError' })

const ErrorGroup = error({
  name: 'ErrorGroup',
  inherits: [BaseError],
  args: z.object({
    errors: z.array(z.unknown()).min(1),
  }),
  message: ({ errors }) => `Group of ${errors.length} error(s)`,
})

// Instantiation -- no `new`, just the factory call
const group = ErrorGroup({ errors: [validationErr, notFoundErr] })

group.errors is a ReadonlyArray<ErrorInstance> and the instance itself is iterable via Symbol.iterator over .errors.

2. Type-safe discrimination via is()

is(group, ErrorGroup) // true

The existing is() function narrows group.fields.errors[i] to the matching type when the user passes the constructor. No new runtime primitive needed.

3. .split(condition) -- typed deaggregation

A method on every ErrorGroup instance. Returns a tuple [matched, rest] where both halves are ErrorGroup instances (or null when empty).

// Single class
const [validation, rest] = group.split(ValidationError)
// validation: ErrorGroup<{ errors: ReadonlyArray<ValidationError> }>
// rest:       ErrorGroup<{ errors: ReadonlyArray<Excluded<...>> }>     | null

// Array of classes
const [a, b] = group.split([ValidationError, NotFoundError])

// Predicate
const [badRequests, rest2] = group.split((e) =>
  is(e, ValidationError) && e.fields.field === 'email'
)

split is recursive by default (descends into nested subgroups, PEP 654 semantics). Pass { recursive: false } for the flat variant.

4. .match().with(...).exhaustive() -- exhaustive dispatch

Inspired by ts-pattern. Chainable method that delivers except*-like ergonomics without new syntax:

group
  .match()
  .with(ValidationError, (e) => {
    // e is narrowed to ValidationError; e.fields.field is typed
    console.warn('field:', e.fields.field)
  })
  .with(NotFoundError, (e) => {
    // e is narrowed to NotFoundError; e.fields.id is typed
    console.warn('id:', e.fields.id)
  })
  .exhaustive() // compile error if any branch is missing

This is the single most valuable part of the feature: it replaces instanceof ladders with a compiler-checked exhaustive switch.

5. Composition with .from()

ErrorGroup integrates with existing cause chains without changes:

const wrapped = ValidationError({ field: 'batch' }).from(group)
// wrapped.cause is `group`; `group.errors` is reachable via causes()

No new cause semantics; group is just another error.

6. Optional interop helpers (later, v3+ if needed)

  • fromAggregateError(agg) -- wrap a native AggregateError into an ErrorGroup.
  • fromAllSettled(results) -- build an ErrorGroup from Promise.allSettled rejections.

These are not part of v2. They are documented as future work to avoid scope creep.

Constraints respected

  • No classes. ErrorGroup is an error({...}) factory like every other error.
  • No instanceof. Type narrowing goes through is().
  • No new. Instantiation is ErrorGroup({ errors }).
  • Composition over inheritance. inherits: [BaseError], not extends.
  • Methods over free functions. .split(), .match() are methods on the instance.
  • Properties over methods. .errors is a property, not .errors().

Relationship to #32

#32 replaces the {placeholder} template system with args: z.ZodType + message: (args) => string. This feature depends on #32 because the canonical declaration of an ErrorGroup is:

const ErrorGroup = error({
  name: 'ErrorGroup',
  inherits: [BaseError],
  args: z.object({ errors: z.array(z.unknown()).min(1) }),
  message: ({ errors }) => `Group of ${errors.length} error(s)`,
})

Without #32, the args field does not exist and the same effect would require a runtime convention (e.g. a magic errors key in the template). The two features compose naturally; #32 should be merged first or in parallel.

Alternatives Considered

  • Status quo (Promise.allSettled + manual accumulation). Works but reinvents the wheel per project; no type safety on the aggregated list; no exhaustive dispatch.
  • Native AggregateError only. Lacks typing, lacks split/match, no inherits:, no factory pattern. A wrapper is still needed to satisfy our conventions; that wrapper is essentially what this issue proposes.
  • Subclassing Error directly (class ErrorGroup extends Error). Violates the documented "factory over class" design philosophy. Rejected.
  • Reactive stream / Result-type-based approach (Result<T, E[]> everywhere). Too large a paradigm shift; @deessejs/errors is throw-based by design. Rejected.
  • Defer to v3. The argument for v1 was "no except*, complexity not worth it." The complexity is now bounded (one factory, two methods, one helper) and the value is high (typed exhaustiveness). Re-defining as v2 instead of v3 is justified by the small surface area and the [Refactor]: Promote StandardSchemaV1 fields to runtime validation + message-as-function #32 dependency.
  • Provide split only, no match. Considered; match is the feature that delivers except*-like value. Half-measure rejected.
  • Reuse the existing causes() function as the iteration primitive. Considered; causes() walks the .from() chain (depth 1, linear), while split/match work on the array inside an ErrorGroup (breadth-first, recursive). Different semantics. Kept separate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions