“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
/** 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.
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.
Stop at the first invalid result
Use when later work is unsafe or a single failure voids the whole operation.
Run all, collect reasons
Use when every error matters, but add association before promising recovery.
Return the successful partition
Use when completed work remains valid and failed inputs can be handled independently.
| Contract | After first failure | Caller receives |
|---|---|---|
| Fail fast | Stop or cancel siblings | One failure and no partial result |
| Aggregate | Continue every item | Several reasons, often without input identity |
| Partial success | Continue every item | Successes plus keyed, actionable failures |
F2 in TypeScript · 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);
} F2 in Go · errors.Join
// 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
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));
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.
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.
Keep the inputs fixed. Change the failure contract.
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
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.
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.
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.
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.
Define the unit
Choose whether one item or the whole batch is the transaction.
Run with a policy
Stop, collect, or continue under bounded concurrency and cancellation rules.
Reconcile by key
Keep useful results and retry or repair only named failures.
TypeScript · 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 · 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.
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.
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.
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.
Fail fast.
Stop or cancel work and return one honest failure path.
Aggregate by key.
Collect the reasons with the input that produced each one.
Return the partition.
Keep usable successes and name exact retry candidates.
“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
- Result types and combinators compose one operation’s expected failure.
- Retry, backoff, and idempotency is the recovery policy once the partition is known.
- Structured concurrency decides what happens to the other tasks when one fails.
- Errors across a boundary turns the keyed report into a response a client can act on.