You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Aggregate them into a single, throwable error so the call site can observe all failures in one place.
Deaggregate them later by type, in a type-safe way, without instanceof ladders.
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.
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'constBaseError=error({name: 'BaseError'})constErrorGroup=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 callconstgroup=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 classconst[validation,rest]=group.split(ValidationError)// validation: ErrorGroup<{ errors: ReadonlyArray<ValidationError> }>// rest: ErrorGroup<{ errors: ReadonlyArray<Excluded<...>> }> | null// Array of classesconst[a,b]=group.split([ValidationError,NotFoundError])// Predicateconst[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.
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 typedconsole.warn('field:',e.fields.field)}).with(NotFoundError,(e)=>{// e is narrowed to NotFoundError; e.fields.id is typedconsole.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:
constwrapped=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().
#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:
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.
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/errorsoffers no first-class way to:instanceofladders..from(cause)cause chains.The internal
docs/internal/product/design-philosophy.mdalready calls out the gap under "Why not ExceptionGroup?" -- acknowledging the limitation while deferring implementation to v1. Thedocs/internal/product/README.mdlists "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
fieldsobject, it becomes natural and clean to compose az.array(z.union([E1, E2, E3]))describing a typed collection of errors. Without groups, the new typed-args system still forces users to either:Real-world use cases
ValidationErrorinstead of stopping at the first.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.EmailError,PaymentError,ProfileError). The user wants a single error surface, with the ability to inspect each branch.Why now (and not v1)
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.ErrorGroupcan be defined aserror({ name: 'ErrorGroup', inherits: [BaseError], args, message })-- noclass X extends Errorrequired, noinstanceof, nonew.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.Promise.allSettledcovers 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, noextends, noinstanceof. All operations are methods on the instance or helpers exported alongside it.1.
ErrorGroupfactorygroup.errorsis aReadonlyArray<ErrorInstance>and the instance itself is iterable viaSymbol.iteratorover.errors.2. Type-safe discrimination via
is()The existing
is()function narrowsgroup.fields.errors[i]to the matching type when the user passes the constructor. No new runtime primitive needed.3.
.split(condition)-- typed deaggregationA method on every
ErrorGroupinstance. Returns a tuple[matched, rest]where both halves areErrorGroupinstances (ornullwhen empty).splitis recursive by default (descends into nested subgroups, PEP 654 semantics). Pass{ recursive: false }for the flat variant.4.
.match().with(...).exhaustive()-- exhaustive dispatchInspired by
ts-pattern. Chainable method that deliversexcept*-like ergonomics without new syntax:This is the single most valuable part of the feature: it replaces
instanceofladders with a compiler-checked exhaustive switch.5. Composition with
.from()ErrorGroupintegrates with existing cause chains without changes:No new
causesemantics;groupis just another error.6. Optional interop helpers (later, v3+ if needed)
fromAggregateError(agg)-- wrap a nativeAggregateErrorinto anErrorGroup.fromAllSettled(results)-- build anErrorGroupfromPromise.allSettledrejections.These are not part of v2. They are documented as future work to avoid scope creep.
Constraints respected
ErrorGroupis anerror({...})factory like every other error.instanceof. Type narrowing goes throughis().new. Instantiation isErrorGroup({ errors }).inherits: [BaseError], notextends..split(),.match()are methods on the instance..errorsis a property, not.errors().Relationship to #32
#32 replaces the
{placeholder}template system withargs: z.ZodType+message: (args) => string. This feature depends on #32 because the canonical declaration of anErrorGroupis:Without #32, the
argsfield does not exist and the same effect would require a runtime convention (e.g. a magicerrorskey in the template). The two features compose naturally; #32 should be merged first or in parallel.Alternatives Considered
Promise.allSettled+ manual accumulation). Works but reinvents the wheel per project; no type safety on the aggregated list; no exhaustive dispatch.AggregateErroronly. Lacks typing, lacks split/match, noinherits:, no factory pattern. A wrapper is still needed to satisfy our conventions; that wrapper is essentially what this issue proposes.Errordirectly (class ErrorGroup extends Error). Violates the documented "factory over class" design philosophy. Rejected.Result<T, E[]>everywhere). Too large a paradigm shift;@deessejs/errorsis throw-based by design. Rejected.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.splitonly, nomatch. Considered;matchis the feature that deliversexcept*-like value. Half-measure rejected.causes()function as the iteration primitive. Considered;causes()walks the.from()chain (depth 1, linear), whilesplit/matchwork on the array inside anErrorGroup(breadth-first, recursive). Different semantics. Kept separate.