← Concepts & practices
Pattern Errors, results, and recovery

Aggregate & partial failure

Which results remain useful when a batch is uneven?

Five independent invites do not become one indivisible failure just because two of them fail. A migration may need the opposite rule: one broken table makes the whole operation unsafe. Choose the batch contract from the meaning of partial success, then carry each input beside its outcome so the caller can act on what actually happened.

The judgment to keep

Fail fast when the operation is all-or-nothing. Aggregate when every failure matters. Return keyed partial success when completed work is useful and failed inputs can be retried or repaired exactly.

TypeScriptGo One five-item batch · three batch contracts
Start with the batch

“Some failed” is not a complete result.

A batch of independent work has more than two meaningful states. All five invites may succeed, one may fail while four are useful, or the whole operation may need to stop before its effects become unsafe. A single error or AggregateError can report that something went wrong; it does not by itself say what the caller should keep.

There are two questions before choosing a helper. Is work after the first failure still valid? And if several outcomes remain, can the caller associate each one with its input? An error list without keys can be useful for a log and useless for an exact retry.

The shape of the result should follow the meaning of the operation, not the convenience of the parallel primitive.

Read the smallest operationTypeScript · one outcome per input
aggregate.ts · one outcome
/** The operation has a result for each invite, but the failure shape is still local. */
export function sendInvite(item: string, pattern: FailurePattern): InviteResult {
	return attempt(item, pattern);
}

The individual result knows whether one invite succeeded. The batch still has to decide whether to stop, collect, or preserve every result with its input.

Three contracts

Fail fast, collect, or report the partition.

Fail-fast tools such as an errgroup-style worker group stop or cancel sibling work when one failure invalidates the whole operation. Aggregate tools such as AggregateError or errors.Join retain several reasons, but the reasons need an input key before they can guide recovery. A partial report makes both sides explicit: successful items and keyed failures.

F1 · Fail fast

Stop at the first invalid result

Use when later work is unsafe or a single failure voids the whole operation.

F2 · Aggregate

Run all, collect reasons

Use when every error matters, but add association before promising recovery.

F3 · Partial success

Return the successful partition

Use when completed work remains valid and failed inputs can be handled independently.

What each contract promises
ContractAfter first failureCaller receives
Fail fastStop or cancel siblingsOne failure and no partial result
AggregateContinue every itemSeveral reasons, often without input identity
Partial successContinue every itemSuccesses plus keyed, actionable failures
TypeScript

F2 in TypeScript · AggregateError

aggregate.ts · AggregateError
/** AggregateError keeps several reasons, but this return value no longer names their inputs. */
export function canonicalBatch(pattern: FailurePattern): string[] | AggregateError {
	const results = invites.map((item) => sendInvite(item, pattern));
	const failures = results.filter((result) => !result.ok).map((result) => result.error.reason);
	if (failures.length > 0) return new AggregateError(failures, 'Some invites failed');
	return results
		.filter((result): result is Extract<InviteResult, { ok: true }> => result.ok)
		.map((result) => result.item);
}
Go

F2 in Go · errors.Join

canonical.go · joined errors
// RunCanonical collects reasons but has no input key beside each error.
func RunCanonical(items []string) ([]string, error) {
	var failures []error
	var succeeded []string
	for _, item := range items {
		failure := failureFor(item)
		if failure != nil {
			failures = append(failures, failure)
			continue
		}
		succeeded = append(succeeded, item)
	}
	if len(failures) > 0 {
		return nil, errors.Join(failures...)
	}
	return succeeded, nil
}

Both keep every reason: errors.Is still finds each joined error, and AggregateError.errors lists them. Neither says which invite produced which reason, which is what the report in the production section adds.

See the complete programsCopyable source plus invocation
TypeScript
aggregate.ts
export type FailurePattern = 'none' | 'one' | 'multiple';

export type InviteFailure = Readonly<{ reason: string; retryable: boolean }>;

export type InviteResult =
	Readonly<{ ok: true; item: string }> | Readonly<{ ok: false; error: InviteFailure }>;

export type ItemFailure = InviteFailure & Readonly<{ item: string }>;

export type BatchReport = Readonly<{
	succeeded: string[];
	failed: ItemFailure[];
}>;

type FailedAttempt = Readonly<{
	item: string;
	result: Extract<InviteResult, { ok: false }>;
}>;

const invites = ['Mina', 'Omar', 'Ivo', 'Rae', 'Tala'];

function attempt(item: string, pattern: FailurePattern): InviteResult {
	if (pattern === 'one' && item === 'Rae') {
		return { ok: false, error: { reason: 'provider timeout', retryable: true } };
	}
	if (pattern === 'multiple' && item === 'Omar') {
		return { ok: false, error: { reason: 'invalid address', retryable: false } };
	}
	if (pattern === 'multiple' && item === 'Tala') {
		return { ok: false, error: { reason: 'provider timeout', retryable: true } };
	}
	return { ok: true, item };
}

/** The operation has a result for each invite, but the failure shape is still local. */
export function sendInvite(item: string, pattern: FailurePattern): InviteResult {
	return attempt(item, pattern);
}

/** AggregateError keeps several reasons, but this return value no longer names their inputs. */
export function canonicalBatch(pattern: FailurePattern): string[] | AggregateError {
	const results = invites.map((item) => sendInvite(item, pattern));
	const failures = results.filter((result) => !result.ok).map((result) => result.error.reason);
	if (failures.length > 0) return new AggregateError(failures, 'Some invites failed');
	return results
		.filter((result): result is Extract<InviteResult, { ok: true }> => result.ok)
		.map((result) => result.item);
}

/** Carry the input beside every outcome so a caller can retry exactly the failed work. */
export function batchWithAssociation(pattern: FailurePattern): BatchReport {
	const results = invites.map((item) => ({ item, result: sendInvite(item, pattern) }));
	const failed = results.filter((entry): entry is FailedAttempt => !entry.result.ok);
	return {
		succeeded: results.filter(({ result }) => result.ok).map(({ item }) => item),
		failed: failed.map(({ item, result }) => ({ item, ...result.error }))
	};
}

export function retryableItems(report: BatchReport): string[] {
	return report.failed.filter((failure) => failure.retryable).map((failure) => failure.item);
}

export function observe(pattern: FailurePattern) {
	const canonical = canonicalBatch(pattern);
	const report = batchWithAssociation(pattern);
	return {
		canonical:
			canonical instanceof AggregateError
				? { kind: 'aggregate-error', reasons: canonical.errors }
				: { kind: 'success', items: canonical },
		report,
		retry: retryableItems(report)
	};
}

export function runExample() {
	return observe('multiple');
}

console.log(JSON.stringify(runExample(), null, 2));
Go
invites.go
package main

import (
	"errors"
	"fmt"
	"strings"
)

var ErrInvalidAddress = errors.New("invalid address")
var ErrProviderTimeout = errors.New("provider timeout")

// RunCanonical collects reasons but has no input key beside each error.
func RunCanonical(items []string) ([]string, error) {
	var failures []error
	var succeeded []string
	for _, item := range items {
		failure := failureFor(item)
		if failure != nil {
			failures = append(failures, failure)
			continue
		}
		succeeded = append(succeeded, item)
	}
	if len(failures) > 0 {
		return nil, errors.Join(failures...)
	}
	return succeeded, nil
}


func failureFor(item string) error {
	switch item {
	case "Omar":
		return ErrInvalidAddress
	case "Tala":
		return ErrProviderTimeout
	default:
		return nil
	}
}

type ItemFailure struct {
	Item      string
	Reason    string
	Retryable bool
}

type BatchReport struct {
	Succeeded []string
	Failed    []ItemFailure
}

// RunWithAssociation keeps the input beside its outcome.
func RunWithAssociation(items []string) BatchReport {
	var report BatchReport
	for _, item := range items {
		if err := failureFor(item); err != nil {
			report.Failed = append(report.Failed, ItemFailure{
				Item:      item,
				Reason:    err.Error(),
				Retryable: errors.Is(err, ErrProviderTimeout),
			})
			continue
		}
		report.Succeeded = append(report.Succeeded, item)
	}
	return report
}

func RetryableItems(report BatchReport) []string {
	var items []string
	for _, failure := range report.Failed {
		if failure.Retryable {
			items = append(items, failure.Item)
		}
	}
	return items
}

func main() {
	invites := []string{"Mina", "Omar", "Ivo", "Rae", "Tala"}

	_, err := RunCanonical(invites)
	fmt.Printf("canonical reasons: %q\n", strings.Split(err.Error(), "\n"))

	report := RunWithAssociation(invites)
	fmt.Printf("succeeded: %v\n", report.Succeeded)
	for _, failure := range report.Failed {
		fmt.Printf("failed: %s (%s, retryable %t)\n", failure.Item, failure.Reason, failure.Retryable)
	}
	fmt.Printf("retry: %v\n", RetryableItems(report))
}

Save the TypeScript as aggregate.ts and run node aggregate.ts (Node 22.18 or later runs TypeScript directly). Save the Go as invites.go and run go run invites.go. Both send the same five invites and print the joined reasons, the keyed report, and the one invite worth retrying.

Run the batch

The input key is the difference between a report and a recovery plan.

Choose a strategy and inject one or two failures. Notice how fail-fast leaves work unattempted, aggregation reports reasons without enough identity, and partial success preserves the exact names needed for a retry.

A five-item batch

Keep the inputs fixed. Change the failure contract.

Runs a local batch model
Mina succeeded
Omar invalid address
Ivo succeeded
Rae succeeded
Tala provider timeout
5/5items attempted
3successes returned
2failures named
3 succeeded; 2 item outcomes returned

Each failure carries its item key and reason

Recovery Retry exactly: Tala

The caller can keep usable successes and retry only the failures whose inputs are named.

The controls change a local model; nothing is saved.
Read the TypeScript call siteTypeScript · keep successes and retryable failures
aggregate.ts
export function observe(pattern: FailurePattern) {
	const canonical = canonicalBatch(pattern);
	const report = batchWithAssociation(pattern);
	return {
		canonical:
			canonical instanceof AggregateError
				? { kind: 'aggregate-error', reasons: canonical.errors }
				: { kind: 'success', items: canonical },
		report,
		retry: retryableItems(report)
	};
}

export function runExample() {
	return observe('multiple');
}

The report distinguishes “failed” from “retryable.” An invalid address should be corrected or discarded; a provider timeout can be attempted again. Both facts belong beside the item that produced them.

Make the boundary explicit

Choose the contract before choosing the combinator.

The same loop can be correct or dangerously misleading depending on the operation’s semantics. Practice naming the promised outcome first.

A destructive migration is valid only if every table changes.
Five independent invites should send what they can and retry only failed addresses.
A form should show every invalid field in one response.
Feedback stays on this page; it is not saved.
Implement the contract

Keep the useful work and name what can recover.

A team-invite batch has already run. Build a report that keeps successful invites, separates temporary failures from permanent ones, and carries each failure reason beside its ID. The workspace runs the same checks in TypeScript and Go.

Aggregate & partial failure practice 9 min

Keep each invite outcome actionable

This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback

Checking your sign-in status. Your lesson remains available while we check.

TypeScript Go

Aggregate & partial failure practice 9 min

This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback

Keep each invite outcome actionable

TYPESCRIPT

Work item TEAM-236

Keep each invite outcome actionable

Implementation exercise Ready

Context

A team sends invitations to many addresses in one batch. The provider returns one outcome per attempt: some were sent, some failed temporarily, and some failed permanently. The caller needs to keep successful work and retry only the failures that can recover.

Acceptance criteria
  1. AC-1Put successful invite IDs in sentIDs and do not stop when another invite failed.
  2. AC-2Put retryable failure IDs and their reasons in retryableIDs and retryableReasons.
  3. AC-3Put permanent failure IDs and their reasons in permanentFailureIDs and permanentFailureReasons.
  4. AC-4Keep original order within each group, and keep every reason beside its matching failure ID.
Notes
  • The input contains completed attempt outcomes; this function reports them and does not send invitations.
  • An ID is the stable identity the caller uses to reconcile or retry an item.
  • An empty batch returns empty groups. Do not infer retryability from the reason text.

Copy the ticket to research the problem in your own notes or AI tool. Your code stays here.

Your implementation

Edit the function in the editor. Run the visible checks as often as you like; your code stays in this tab.

Checks cover

  • Mixed outcomes keep successes and both failure kinds
  • Every success is retained in its original order
  • Failure reasons stay aligned with their IDs
  • An empty batch returns empty groups
A production batch

Make retry and reconciliation first-class outputs.

A useful batch report is more than successes and errors. Give failures an input key, a stable reason, and the recovery policy the owner supports. Keep enough information to reconcile a timeout whose server-side effect is uncertain; “retryable” should not mean “safe to duplicate without an idempotency key.”

At a service boundary, return the partition deliberately and document whether the caller may submit only failed items. At a worker boundary, cancellation and concurrency limits still matter: a partial contract does not require launching every item at once.

Planner

Define the unit

Choose whether one item or the whole batch is the transaction.

Worker

Run with a policy

Stop, collect, or continue under bounded concurrency and cancellation rules.

Caller

Reconcile by key

Keep useful results and retry or repair only named failures.

TypeScript

TypeScript · keyed report

aggregate.ts · keyed report
/** Carry the input beside every outcome so a caller can retry exactly the failed work. */
export function batchWithAssociation(pattern: FailurePattern): BatchReport {
	const results = invites.map((item) => ({ item, result: sendInvite(item, pattern) }));
	const failed = results.filter((entry): entry is FailedAttempt => !entry.result.ok);
	return {
		succeeded: results.filter(({ result }) => result.ok).map(({ item }) => item),
		failed: failed.map(({ item, result }) => ({ item, ...result.error }))
	};
}

export function retryableItems(report: BatchReport): string[] {
	return report.failed.filter((failure) => failure.retryable).map((failure) => failure.item);
}
Go

Go · keyed report

twin.go · keyed report
type ItemFailure struct {
	Item      string
	Reason    string
	Retryable bool
}

type BatchReport struct {
	Succeeded []string
	Failed    []ItemFailure
}

// RunWithAssociation keeps the input beside its outcome.
func RunWithAssociation(items []string) BatchReport {
	var report BatchReport
	for _, item := range items {
		if err := failureFor(item); err != nil {
			report.Failed = append(report.Failed, ItemFailure{
				Item:      item,
				Reason:    err.Error(),
				Retryable: errors.Is(err, ErrProviderTimeout),
			})
			continue
		}
		report.Succeeded = append(report.Succeeded, item)
	}
	return report
}

func RetryableItems(report BatchReport) []string {
	var items []string
	for _, failure := range report.Failed {
		if failure.Retryable {
			items = append(items, failure.Item)
		}
	}
	return items
}
Build UIs?A batch report is a view model with recovery data.

Where it already is in your components

Bulk forms, import previews, upload queues, and permission editors often need to show successes beside item-level errors. Let the request layer return a keyed report so a row can render its own state without guessing which error belongs to it.

When you have to own it

When a UI can retry one failed row, preserve the original identifier and any server version or idempotency token it needs. When the operation is all-or-nothing, keep the partial worker details in logs and give the view one honest failure state instead.

Recognize it elsewhere

Batch APIs reveal the same choice in different clothes.

Promise.all

Fail-fast aggregation is concise, but it does not promise a usable partial result.

Promise.allSettled

Every item settles; carry the input explicitly if the array can be reordered or transformed.

Bulk import reports

Rows with stable IDs turn “some failed” into a correction list rather than a support ticket.

Translate the report at a public boundary ↗
The parts to watch

Accumulation has costs and sharp edges.

Cancellation changes the promise

A fail-fast group may return quickly while already-started work is still unwinding, or it may cancel siblings before they produce outcomes. Do not present “not attempted” as “failed”; they are different states with different recovery actions.

Joined errors are not a keyed report

AggregateError and errors.Join can preserve multiple causes and support category checks. They do not automatically tell your product which row to highlight or which request to resubmit. Add that association at the operation boundary.

Partial success needs an ownership rule

If the caller may retry one item, say who owns deduplication, ordering, and duplicate effects. A partial report without reconciliation semantics can turn a transient timeout into a duplicate write.

Accumulation can hide severity

Collecting ten validation problems is helpful. Collecting a process invariant beside nine ordinary input errors may be dangerous. Keep unrecoverable defects on their own path instead of treating all failures as equally reportable.

Make the call

Choose the smallest honest result.

Start from the business meaning of “done.” Then make the result carry enough identity for the next action—especially if that action is retry, repair, or reconciliation.

All-or-nothing

Fail fast.

Stop or cancel work and return one honest failure path.

Every error matters

Aggregate by key.

Collect the reasons with the input that produced each one.

Independent work

Return the partition.

Keep usable successes and name exact retry candidates.

Take the idea with you

“Some failed” should name the next move.

A batch result is a map of work, not merely a boolean with a longer error string. Decide whether later work is valid, then preserve the association that lets the caller keep, repair, or retry each item honestly.

This lesson decides what the partition is. The recovery policy comes after it.

Why
Make the batch outcome match the meaning of partial completion.
What
Stop, collect, or return successes and keyed failures.
Constraint
Each item is independent, and repeating one is safe or detectable.
Fallback
Fail fast when partial success has no valid meaning.
Reconsider when
The transaction unit or recovery action changes.
Connections to follow nextRelated lessons
Explore more concepts & practices →