Skip to content

Form error mapping

@codewithagents/api-errors maps backend API error responses to form field errors at runtime. It normalizes every common server error format into a flat { field, message }[] list, then wires it to your form library in one call. The validation-to-UX gap: your backend returns structured errors, your form library needs setError calls per field, this package bridges the two without any codegen step.

Two public functions cover all use cases:

FunctionWhat it does
extractFieldErrors(error, options?)Parses any error shape and returns normalized FieldError[]. Framework-agnostic.
mapApiErrors(error, setError, options?)Calls extractFieldErrors internally, then calls setError once per field. React Hook Form adapter.

Key guarantees:

  • Runtime, not codegen. Import and call: no generator step, no config file.
  • Zero runtime dependencies. Pure TypeScript, nothing added to your bundle.
  • Never throws. Both functions return [] (or call setError zero times) for unrecognized or null input.
  • Multiple error formats. RFC 7807 / RFC 9457 Problem Details, Spring Boot validation arrays, flat objects, flat arrays. Detected automatically.
  • Response unwrapping. Handles ApiError from @codewithagents/openapi-gen, Axios-style error.response.data, and generic { data: ... } wrappers before parsing.
  • Typed. Ships full TypeScript declarations. No @types package needed.
Terminal window
npm i @codewithagents/api-errors

React Hook Form: one call at the catch site.

import { useForm } from 'react-hook-form'
import { mapApiErrors } from '@codewithagents/api-errors'
function SignupForm() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm()
const onSubmit = async (data) => {
try {
await api.post('/signup', data)
} catch (error) {
// Maps backend field errors to RHF setError calls automatically
mapApiErrors(error, setError)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Sign up</button>
</form>
)
}

Without React Hook Form: use extractFieldErrors directly.

See the framework-agnostic usage section below for a fuller example with custom state management.

function extractFieldErrors(error: unknown, options?: MapApiErrorsOptions): FieldError[]

Parses error and returns a normalized list of field errors. Returns [] for any unrecognized shape. Never throws.

function mapApiErrors(
error: unknown,
setError: (field: string, error: { type: string; message: string }) => void,
options?: MapApiErrorsOptions
): void

Calls extractFieldErrors(error, options) internally, then calls setError once per field with type: 'server'. The setError signature is compatible with React Hook Form’s UseFormSetError<T>. No casting needed.

interface MapApiErrorsOptions {
/**
* Field name used when no field can be determined from the error.
* Defaults to 'root'.
*/
fallbackField?: string
/**
* Transform applied to every resolved field name before it is returned.
* Useful for mapping backend camelCase names to nested RHF dot-paths,
* e.g. "addressCity" to "address.city".
* Also applied to fallbackField.
*/
transformField?: (field: string) => string
/**
* Restrict extraction to specific HTTP status codes.
* If the error carries a detectable status code not in this list, returns [] immediately.
* If no status code can be detected on the error, the filter is bypassed and
* parsing proceeds normally.
* Useful to avoid parsing 404 or 500 bodies as field errors.
*/
statusCodes?: number[]
}

statusCodes pass-through behaviour: the filter only fires when a status code is found on the error object (error.status or error.response.status). If no status is present, parsing always proceeds regardless of the statusCodes list. This means a raw fetch body passed directly to extractFieldErrors is always parsed.

interface FieldError {
field: string
message: string
}
import type { FieldError, MapApiErrorsOptions } from '@codewithagents/api-errors'

The format is detected automatically. No configuration is required.

Parsers are tried in a fixed order and the first match wins: a top-level flat array (when the body itself is an array) is handled first, then the RFC 7807 errors map, then the Spring Boot array, then a flat object, then the RFC 9457 detail. A body that contains both an errors map and a detail field uses the errors map; the detail field is ignored.

RFC 7807 / RFC 9457 Problem Details (Spring Boot 3+)

Section titled “RFC 7807 / RFC 9457 Problem Details (Spring Boot 3+)”

errors is an object where each key is a field name and each value is a string or array of strings.

{
"type": "https://example.com/errors/validation",
"title": "Validation failed",
"status": 400,
"errors": {
"email": ["must not be blank"],
"name": ["too short", "must not contain numbers"]
}
}

Multiple messages for the same field each become a separate FieldError entry.

When no field-specific errors are found, the top-level detail string is returned as a root-level error (using fallbackField). This fires as a last-resort fallback.

{
"title": "Bad Request",
"detail": "Email is already taken.",
"status": 422
}

Result: [{ field: 'root', message: 'Email is already taken.' }]

Spring Boot default validation format (pre-3)

Section titled “Spring Boot default validation format (pre-3)”

errors is an array of objects with field and defaultMessage (or message) keys.

{
"status": 400,
"errors": [
{ "field": "email", "defaultMessage": "must not be blank" },
{ "field": "name", "defaultMessage": "too short" }
]
}

When an item has neither defaultMessage nor message, the message is set to 'Unknown error'. Check for this sentinel if you need to localize or suppress it:

const errors = extractFieldErrors(apiError)
const mapped = errors.map((e) => ({
...e,
message: e.message === 'Unknown error' ? t('errors.unknown') : e.message,
}))
{ "field": "email", "message": "Invalid email" }
[
{ "field": "email", "message": "Invalid email" },
{ "field": "name", "message": "Required" }
]

Before parsing, the following wrappers are stripped:

Wrapper shapeDetected by
{ status: number, body: unknown }ApiError from @codewithagents/openapi-gen
{ response: { data: ... } }Axios-style error objects
{ data: ... }Generic response wrappers (only when data is a plain object and field / errors are absent at the top level)

mapApiErrors accepts any setError matching (field: string, error: { type: string; message: string }) => void, which is exactly what React Hook Form’s typed UseFormSetError<T> provides. No casting:

import { useForm } from 'react-hook-form'
import { mapApiErrors } from '@codewithagents/api-errors'
type FormValues = { email: string; name: string }
function SignupForm() {
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<FormValues>()
const onSubmit = async (data: FormValues) => {
try {
await createUser(data)
} catch (error) {
// setError is typed to FormValues, no cast needed
mapApiErrors(error, setError)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
<button type="submit">Sign up</button>
</form>
)
}

With the generated ApiError and statusCodes

Section titled “With the generated ApiError and statusCodes”

When you use @codewithagents/openapi-gen, the generated client throws ApiError on non-2xx responses. Pass it directly to extractFieldErrors. Use statusCodes to restrict field-error parsing to validation responses only:

import { createTask } from './src/api'
import { mapApiErrors } from '@codewithagents/api-errors'
// In a React Hook Form onSubmit handler:
try {
await createTask({ title: formData.title })
} catch (error) {
// Only parse field errors for 422 Unprocessable Entity.
// 404 and 500 errors are returned as-is (setError is not called).
mapApiErrors(error, setError, { statusCodes: [422] })
}

With React Query useMutation:

import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { mapApiErrors } from '@codewithagents/api-errors'
function CreateTaskForm() {
const { handleSubmit, setError } = useForm<FormValues>()
const mutation = useMutation({
mutationFn: createTask,
onError: (error) => mapApiErrors(error, setError, { statusCodes: [422] }),
})
return <form onSubmit={handleSubmit((data) => mutation.mutate(data))}>...</form>
}

Pass the parsed response body directly to extractFieldErrors when not using a library that throws typed errors:

import { extractFieldErrors } from '@codewithagents/api-errors'
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const body = await res.json()
const fieldErrors = extractFieldErrors(body)
for (const { field, message } of fieldErrors) {
setError(field, { type: 'server', message })
}
}

Use transformField to map backend camelCase names to nested React Hook Form dot-paths:

mapApiErrors(error, setError, {
transformField: (f) => f.replace(/([A-Z])/g, '.$1').toLowerCase(),
// "emailAddress" → "email.address"
// "streetCity" → "street.city"
})

transformField is also applied to fallbackField, so the transform is consistent across all field names.

When a backend returns multiple errors for the same field, extractFieldErrors returns all of them as separate entries. If you pass them directly to React Hook Form’s setError, the last call wins and only the last message is displayed. To show all messages, group first:

import { extractFieldErrors } from '@codewithagents/api-errors'
const fieldErrors = extractFieldErrors(error)
const grouped = Map.groupBy(fieldErrors, (e) => e.field)
for (const [field, errs] of grouped) {
setError(field, {
type: 'server',
message: errs.map((e) => e.message).join(', '),
})
}

extractFieldErrors works with any state management approach. Here is a realistic example with a custom form-state hook (no React Hook Form required):

import { useState } from 'react'
import { extractFieldErrors } from '@codewithagents/api-errors'
type FieldErrors = Record<string, string>
function useFormErrors() {
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
function applyApiError(error: unknown) {
const parsed = extractFieldErrors(error)
if (parsed.length === 0) return false
const next: FieldErrors = {}
for (const { field, message } of parsed) {
// Last message per field wins, consistent with RHF default behaviour
next[field] = message
}
setFieldErrors(next)
return true
}
function clearErrors() {
setFieldErrors({})
}
return { fieldErrors, applyApiError, clearErrors }
}
// Usage in a component:
function SignupForm() {
const { fieldErrors, applyApiError } = useFormErrors()
async function handleSubmit(data: FormValues) {
try {
await api.post('/signup', data)
} catch (error) {
const handled = applyApiError(error)
if (!handled) {
// Unrecognized error shape: re-throw or show a generic banner
console.error('Unexpected error', error)
}
}
}
return (
<form onSubmit={...}>
<input name="email" />
{fieldErrors.email && <p>{fieldErrors.email}</p>}
</form>
)
}

Types and fetch client

@codewithagents/openapi-gen generates the typed ApiError class that api-errors recognizes and unwraps automatically.

Learn more

React Query hooks

@codewithagents/openapi-react-query generates typed useMutation hooks. Use mapApiErrors in mutation onError callbacks to wire server validation directly to form fields.

Learn more

Server interface

@codewithagents/openapi-server generates typed Express and Fastify routers. The server emits the RFC 7807 / RFC 9457 error shapes that api-errors parses on the client.

Learn more