Tutorial

How to Aggregate Form Errors

Collect every validation error in one pass so the user sees all problems at once, instead of fixing them one at a time and resubmitting.

On this page

  1. Introduction
  2. Run safeParse once
  3. Map issues by path
  4. Show a summary list
  5. Link summary to fields
  6. Focus the first error
  7. Test multi-error forms
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of show all errors at once is not writing the code — it is deciding what the code is allowed to reject. A error aggregation setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in error aggregation, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

The cost of show all errors at once without error aggregation is paid in incidents, not in code. The code looks fine because the happy path works, and the happy path is what you demo. The failure path is what you operate, and the failure path is where error aggregation earns its keep. Instrument the rejections, alert on the spikes, and let the metric tell you when the guard is misaligned with reality.

The sections below walk through the concrete decisions: where to put the error aggregation check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Run safeParse once

Run safeParse once is the first decision because it determines what the rest of the error aggregation pipeline can assume. A wrong choice here turns into show all errors at once under load, and the invariant you thought error aggregation guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Run safeParse once is the one most teams skip: drive error aggregation to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

function collectErrors(result) {
  if (result.success) return {};
  return Object.fromEntries(
    result.error.issues.map(i => [i.path.join('.'), i.message])
  );
}

The typescript above is the shape to ship for show all errors at once. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Map issues by path

The mechanism behind Map issues by path is straightforward in error aggregation, but the consequences ripple. A wrong choice here turns into show all errors at once under load, and the invariant you thought error aggregation guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Map issues by path is the one most teams skip: drive error aggregation to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Show a summary list

Show a summary list is where most error aggregation implementations leak performance. A wrong choice here turns into show all errors at once under load, and the invariant you thought error aggregation guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Show a summary list is the one most teams skip: drive error aggregation to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('collects multiple errors', () => {
  const r = S.safeParse({ a: '', b: '' });
  expect(Object.keys(collectErrors(r)).length).toBeGreaterThanOrEqual(2);
});

Pin the behavior with this test. A error aggregation setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

The hard part of Link summary to fields is not the happy path — it is what happens when error aggregation fails halfway. A wrong choice here turns into show all errors at once under load, and the invariant you thought error aggregation guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Link summary to fields is the one most teams skip: drive error aggregation to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Focus the first error

Focus the first error should be observable without a debugger. A wrong choice here turns into show all errors at once under load, and the invariant you thought error aggregation guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Focus the first error is the one most teams skip: drive error aggregation to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test multi-error forms

Test multi-error forms is a trade, not a winner. A wrong choice here turns into show all errors at once under load, and the invariant you thought error aggregation guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test multi-error forms is the one most teams skip: drive error aggregation to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in show all errors at once all reduce to one mistake: treating error aggregation as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the error aggregation boundary so show all errors at once enters unvalidated.
  • Assuming error aggregation is fast without measuring the tail latency under load.
  • Letting show all errors at once fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the error aggregation failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: error aggregation fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives error aggregation to the failing condition and assert the system degrades gracefully. Then show all errors at once is a property you maintain, not a feature you hope for.

Sources: Zod error handling

FAQ

Should I show errors on blur or submit?
Show on blur for the field, and all on submit. Avoid showing all on every keystroke.
How link summary to fields?
Use anchor links or scrollIntoView on the field id.

Related Examples