Types and fetch client
openapi-zod-ts 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.
Three public functions cover all use cases:
| Function | What it does |
|---|---|
extractErrors(error, options?) | Parses any error shape. Returns { fieldErrors, formErrors, format }. Includes form-level errors and the matched format. Recommended for new code. |
extractFieldErrors(error, options?) | Parses any error shape. Returns normalized FieldError[]. Form-level errors fall back to fallbackField. Framework-agnostic. |
mapApiErrors(error, setError, options?) | Calls extractFieldErrors internally, then calls RHF setError once per field. React Hook Form adapter. |
mapApiErrorsToRecord(error, options?) | Returns a Record<string, string> mapping field names to their first error message. Vanilla / framework-agnostic adapter. |
mapApiErrorsFormik(error, setErrors, options?) | Calls Formik’s setErrors with the record produced by mapApiErrorsToRecord. Formik adapter. |
mapApiErrorsTanstack(error, form, options?) | Calls form.setFieldMeta for each field to inject server-side errors. TanStack Form v1 adapter. |
Key guarantees:
ApiError from openapi-zod-ts, 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 extractErrors directly.
See the adapters section below for Formik, TanStack Form, and vanilla usage.
extractErrorsfunction extractErrors(error: unknown, options?: MapApiErrorsOptions): ExtractResultParses error and returns a rich result. Never throws.
interface ExtractResult { /** Field-level errors extracted from the response body. */ fieldErrors: FieldError[] /** * Non-field (global/form-level) error messages. * Messages that could not be attributed to a specific field: * RFC 9457 `detail`, JSON:API errors without a source pointer, * violations/invalid-params entries without a field name, and so on. */ formErrors: string[] /** * The format that was recognized. * `null` when no supported format matched (both arrays are empty). * Use this to distinguish "unrecognized shape" from "recognized but genuinely no errors". */ format: ErrorFormat | null}Use format to drive conditional rendering: a null format means the body shape was not recognized at all, whereas a non-null format with empty arrays means the backend reported no errors.
extractFieldErrorsfunction extractFieldErrors(error: unknown, options?: MapApiErrorsOptions): FieldError[]Parses error and returns a normalized list of field errors. Form-level errors (e.g. RFC 9457 detail) are returned as a FieldError entry using fallbackField ('root' by default). Returns [] for any unrecognized shape. Never throws.
For a richer result that includes form-level errors in a separate channel and the matched format, use extractErrors instead.
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[]
/** * Optional message resolver for i18n or message reformatting. * Called for every error message (field errors and form errors) before the * message is included in the result. Return a replacement string to translate * or reformat the message. * * `field` is `null` for global/form-level errors. * * @example * resolveMessage: (msg, field) => t(`errors.${msg}`) ?? msg */ resolveMessage?: (message: string, field: string | null) => string
/** * Custom parsers tried before the built-in parsers. * Parsers are tried in order; the first one that returns a non-null result wins. * Return `null` to indicate "not recognized by this parser". * * Custom parsers receive the already-unwrapped body (after ApiError / Axios * unwrapping), so they do not need to re-implement body unwrapping. */ parsers?: ReadonlyArray<CustomParser>}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, ExtractResult, ErrorFormat, CustomParser,} from '@codewithagents/api-errors'The format is detected automatically. No configuration is required.
Parsers are applied in this order: custom parsers (if any) run first; if the body is an array, only the flat-array parser runs; if the body is an object, the following parsers are tried in sequence until the first match:
violations: RFC 9457 violations arrayinvalid-params: RFC 9457 invalid-params arrayjson-api: JSON:API errors array with source.pointerrfc7807-map: RFC 7807 / RFC 9457 errors object mapgraphql-extensions: GraphQL errors array with extensionsspring-array: Spring Boot default validation arraylaravel-drf: Laravel / DRF top-level field mapflat-object: simple { field, message } objectzod-flatten: Zod .flatten() output shaperfc9457-detail: RFC 9457 top-level detail string (last-resort fallback)The first parser that returns a non-null result wins. The exact ordering is implementation-defined and may change between minor versions if disambiguation requires it; do not write code that depends on one parser being preferred over another for ambiguous shapes.
errors maperrors is an object where each key is a field name and each value is a string or array of strings. Common with Spring Boot 3+ and custom Problem Details implementations.
{ "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.
violations array{ "title": "Validation failed", "status": 422, "violations": [ { "field": "email", "message": "must not be blank" }, { "field": "name", "message": "too short" } ]}Entries without a field value are surfaced as form-level errors via extractErrors.
invalid-params array{ "title": "Validation failed", "status": 422, "invalid-params": [ { "name": "email", "reason": "must not be blank" }, { "name": "age", "reason": "must be positive" } ]}Errors with a source.pointer value are mapped to field errors. The pointer is converted to a dot-separated field path (/data/attributes/email becomes email). Errors without a pointer (or with /) are returned as form-level errors via extractErrors.
{ "errors": [ { "source": { "pointer": "/data/attributes/email" }, "detail": "must not be blank" }, { "detail": "You do not have permission to perform this action." } ]}extensionsField resolution order: extensions.field (string) is preferred; extensions.path (array joined with .) is used as a fallback. Errors with neither are returned as form-level errors.
{ "errors": [ { "message": "must not be blank", "extensions": { "field": "email" } }, { "message": "Something went wrong." } ]}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'.
A plain object where each key is a field name and each value is a string or array of strings. The body must not contain keys used by other parsers (errors, violations, field, message, detail, etc.).
{ "email": ["must not be blank", "invalid format"], "name": ["required"]}.flatten() outputThe shape produced by Zod’s zodSchema.safeParse(data).error.flatten().
{ "fieldErrors": { "email": ["Invalid email"], "name": ["Required"] }, "formErrors": ["At least one field must be provided."]}formErrors entries are surfaced as form-level errors via extractErrors.
{ "field": "email", "message": "Invalid email" }[ { "field": "email", "message": "Invalid email" }, { "field": "name", "message": "Required" }]detailWhen no other parser matches, the top-level detail string is returned as a form-level error.
{ "title": "Bad Request", "detail": "Email is already taken.", "status": 422}Via extractFieldErrors: [{ field: 'root', message: 'Email is already taken.' }].
Via extractErrors: { fieldErrors: [], formErrors: ['Email is already taken.'], format: 'rfc9457-detail' }.
Before parsing, the following wrappers are stripped:
| Wrapper shape | Detected by |
|---|---|
{ status: number, body: unknown } | ApiError from openapi-zod-ts |
{ 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)function mapApiErrors( error: unknown, setError: (field: string, error: { type: string; message: string }) => void, options?: MapApiErrorsOptions): voidimport { 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) { 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> )}mapApiErrorsToRecord)Returns a Record<string, string> mapping each field to its first error message. Useful for any custom form library or imperative error handling. Only field errors are included; for form-level errors use extractErrors.
function mapApiErrorsToRecord(error: unknown, options?: MapApiErrorsOptions): Record<string, string>import { mapApiErrorsToRecord } from '@codewithagents/api-errors'
try { await submitForm(data)} catch (error) { const record = mapApiErrorsToRecord(error) // { email: "must not be blank", name: "required" } for (const [field, message] of Object.entries(record)) { showFieldError(field, message) }}mapApiErrorsFormik)Calls Formik’s setErrors with a Record<string, string>. No Formik import is required: the setErrors parameter is typed by shape only (structural typing).
function mapApiErrorsFormik( error: unknown, setErrors: (errors: Record<string, string>) => void, options?: MapApiErrorsOptions): voidimport { useFormik } from 'formik'import { mapApiErrorsFormik } from '@codewithagents/api-errors'
function SignupForm() { const formik = useFormik({ initialValues: { email: '', name: '' }, onSubmit: async (values, { setErrors }) => { try { await createUser(values) } catch (error) { mapApiErrorsFormik(error, setErrors) } }, })
return ( <form onSubmit={formik.handleSubmit}> <input name="email" onChange={formik.handleChange} value={formik.values.email} /> {formik.errors.email && <p>{formik.errors.email}</p>} <button type="submit">Sign up</button> </form> )}mapApiErrorsTanstack)Calls form.setFieldMeta for each field to inject server-side errors into the form state. No TanStack Form import is required: the form parameter is typed by shape only (structural typing).
function mapApiErrorsTanstack( error: unknown, form: { setFieldMeta: (field: string, updater: (meta: any) => any) => void }, options?: MapApiErrorsOptions): voidimport { useForm } from '@tanstack/react-form'import { mapApiErrorsTanstack } from '@codewithagents/api-errors'
function SignupForm() { const form = useForm({ defaultValues: { email: '', name: '' }, onSubmit: async ({ value }) => { try { await createUser(value) } catch (error) { mapApiErrorsTanstack(error, form) } }, })
return ( <form onSubmit={(e) => { e.preventDefault() form.handleSubmit() }} > <form.Field name="email"> {(field) => ( <> <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.errors.map((e) => ( <p key={e}>{e}</p> ))} </> )} </form.Field> <button type="submit">Sign up</button> </form> )}extractErrors for form-level errorsextractErrors returns field errors and form-level (global) errors in separate channels. Use formErrors for messages like “You do not have permission” that cannot be attributed to a specific field:
import { extractErrors } from '@codewithagents/api-errors'
try { await createUser(data)} catch (error) { const { fieldErrors, formErrors, format } = extractErrors(error)
if (format === null) { // Unrecognized shape: show a generic banner showBanner('An unexpected error occurred.') return }
for (const { field, message } of fieldErrors) { setError(field, { type: 'server', message }) }
if (formErrors.length > 0) { setFormBanner(formErrors.join(' ')) }}ApiError and statusCodesWhen you use openapi-zod-ts, 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 }) }}resolveMessagePass a resolveMessage function to translate or reformat every error message before it is included in the result. This is the recommended approach for i18n.
import { extractErrors } from '@codewithagents/api-errors'import { useTranslation } from 'react-i18next'
function useApiErrors() { const { t } = useTranslation()
return (error: unknown) => extractErrors(error, { resolveMessage: (msg, field) => t(`errors.${msg}`, { defaultValue: msg }), })}field is null for form-level errors, which lets you apply different translation keys by scope if needed:
resolveMessage: (msg, field) => field === null ? t(`form_errors.${msg}`, { defaultValue: msg }) : t(`field_errors.${msg}`, { defaultValue: msg })As an alternative, you can apply localization manually after extraction. This is useful when you need fine-grained control over which messages to translate:
const errors = extractFieldErrors(apiError)const mapped = errors.map((e) => ({ ...e, message: e.message === 'Unknown error' ? t('errors.unknown') : e.message,}))Register custom parsers via options.parsers to handle application-specific error shapes. Custom parsers run before all built-in parsers. Return null to signal “not recognized” and let the next parser try.
import { extractErrors } from '@codewithagents/api-errors'import type { CustomParser, ParsedErrors } from '@codewithagents/api-errors'
const myParser: CustomParser = (body): ParsedErrors | null => { if ( typeof body !== 'object' || body === null || !('myErrors' in body) || !Array.isArray((body as any).myErrors) ) { return null } const fieldErrors = (body as any).myErrors .filter((e: any) => typeof e.key === 'string' && typeof e.text === 'string') .map((e: any) => ({ field: e.key, message: e.text })) return { fieldErrors, formErrors: [] }}
const result = extractErrors(error, { parsers: [myParser] })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(', '), })}extractErrors 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 { extractErrors } from '@codewithagents/api-errors'
type FieldErrors = Record<string, string>
function useFormErrors() { const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}) const [formErrors, setFormErrors] = useState<string[]>([])
function applyApiError(error: unknown) { const result = extractErrors(error) if (result.format === null) return false
const next: FieldErrors = {} for (const { field, message } of result.fieldErrors) { next[field] = message } setFieldErrors(next) setFormErrors(result.formErrors) return true }
function clearErrors() { setFieldErrors({}) setFormErrors([]) }
return { fieldErrors, formErrors, applyApiError, clearErrors }}
// Usage in a component:function SignupForm() { const { fieldErrors, formErrors, 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={...}> {formErrors.map((msg) => <p key={msg} role="alert">{msg}</p>)} <input name="email" /> {fieldErrors.email && <p>{fieldErrors.email}</p>} </form> )}Types and fetch client
openapi-zod-ts 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 a framework-agnostic service interface plus an optional router for Fastify, Hono, or Express (none by default). The server emits the RFC 7807 / RFC 9457 error shapes that api-errors parses on the client.