typescript 136 lines · 3 tabs

Frontend: normalize and display server validation errors

Shared by codesnips Jan 2026
3 tabs
import axios from 'axios';

export type NormalizedErrors = {
  fields: Record<string, string>;
  formLevel: string | null;
};

type FieldErrorBody = {
  field_errors?: Record<string, string | string[]>;
  errors?: Array<{ path: string; message: string }>;
  detail?: string;
};

function firstMessage(value: string | string[]): string {
  return Array.isArray(value) ? value[0] : value;
}

export function normalizeApiError(err: unknown): NormalizedErrors {
  if (!axios.isAxiosError(err)) {
    return { fields: {}, formLevel: 'Something went wrong. Please try again.' };
  }

  if (!err.response) {
    return { fields: {}, formLevel: 'Network error — check your connection.' };
  }

  if (err.response.status !== 422) {
    return { fields: {}, formLevel: err.response.statusText || 'Request failed.' };
  }

  const body = (err.response.data ?? {}) as FieldErrorBody;
  const fields: Record<string, string> = {};

  if (body.field_errors) {
    for (const [path, value] of Object.entries(body.field_errors)) {
      fields[path] = firstMessage(value);
    }
  }

  if (Array.isArray(body.errors)) {
    for (const { path, message } of body.errors) {
      if (!(path in fields)) fields[path] = message;
    }
  }

  return { fields, formLevel: body.detail ?? null };
}
3 files · typescript Explain with highlit

This snippet shows how a frontend normalizes backend validation errors and surfaces them on the exact fields that caused them, rather than dumping a generic banner. The core problem is that servers describe errors in their own shape — nested paths, arrays of messages, a top-level detail string — while react-hook-form wants a flat map of field name to message plus an optional form-level error. Bridging those two worlds cleanly is what makes a form feel trustworthy.

In errors.ts a discriminated union of NormalizedErrors separates per-field messages from a formLevel fallback. The normalizeApiError function inspects an unknown thrown value using axios.isAxiosError, so it works whether the failure is an HTTP 422 with a structured body, a network error, or an unexpected exception. It supports two common server contracts: a field_errors object keyed by dotted paths and an array of { path, message } entries, collapsing both into Record<string, string>. Dotted paths like address.zip are preserved verbatim because react-hook-form addresses nested fields with that same dot notation, so the mapping stays lossless.

The useServerErrors hook wraps a submit callback and exposes a formError string for anything that cannot attach to a field. On failure it calls normalizeApiError, then uses setError from the form context to place each message on its field with { type: 'server', shouldFocus }, focusing the first offending input. A key detail is that these server errors are transient: clearErrors is wired so that when a user edits a field the stale server message disappears, avoiding the trap where a corrected value still shows the old error. Unknown fields that do not exist in the form fall through to formError instead of being silently dropped.

In SignupForm component the hook is composed with useForm, and each register call is paired with an error message read from formState.errors. Because server and client errors share the same errors object, the JSX renders them identically. The trade-off is a small contract coupling: the frontend must agree with the backend on field naming, which is why normalization is centralized in one place and can be adapted without touching components. This pattern is worth reaching for whenever forms submit to an API that can reject individual fields.


Related snips

Share this code

Here's the card — post it anywhere.

Frontend: normalize and display server validation errors — share card
Link copied