Types and fetch client
@codewithagents/openapi-gen generates the typed ApiError class that api-errors recognizes and unwraps automatically.
@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:
| Function | What 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:
[] (or call setError zero times) for unrecognized or null input.ApiError from @codewithagents/openapi-gen, Axios-style error.response.data, and generic { data: ... } wrappers before parsing.@types package needed.npm i @codewithagents/api-errorspnpm add @codewithagents/api-errorsyarn add @codewithagents/api-errorsReact 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.
extractFieldErrorsfunction extractFieldErrors(error: unknown, options?: MapApiErrorsOptions): FieldError[]Parses error and returns a normalized list of field errors. Returns [] for any unrecognized shape. Never throws.
mapApiErrorsfunction mapApiErrors( error: unknown, setError: (field: string, error: { type: string; message: string }) => void, options?: MapApiErrorsOptions): voidCalls 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.
MapApiErrorsOptionsinterface 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.
FieldErrorinterface 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.
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.
detailWhen 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.' }]
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 shape | Detected 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> )}ApiError and statusCodesWhen 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>}fetchPass 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.
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.
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.