zod-validation-error
Zod throws a ZodError containing an array of issue objects, which is exactly right for machines and useless for a human staring at a form or an API response. zod-validation-error turns that array into one readable sentence and wraps it in a ValidationError class that extends the native Error. Call fromError(err) and you get something whose message reads 'Validation error: Number must be greater than 0 at "id"; Invalid email at "email"', while error.details still holds the original zod issues for your logs. It also ships an error map you can register with zod.config() so that individual issue messages read like English instead of type-checker output, plus type guards for telling a validation failure apart from a real crash across package boundaries. Zero runtime dependencies; zod is a peer.
A small, well-documented helper that solves a real annoyance for API error responses, especially the type guards for separating client errors from server errors. On zod v4 check whether z.prettifyError already does what you need before adding it.
Use it if
- You return HTTP 400 responses with a single message field and want one sentence a client developer or an end user can act on, not a nested issues array they have to render themselves
- You need to distinguish 'the caller sent bad data' from 'our server broke' in a shared error handler, and isValidationErrorLike does it heuristically so it survives two copies of the package existing in one node_modules tree
- You want a real Error subclass you can throw and catch: instanceof Error works, options.cause holds the original ZodError, and error.details exposes the issues for structured logging
- You are on zod v3, where z.prettifyError() does not exist, and you still want readable messages without hand-writing a formatter
- You need formatting control beyond a fixed layout: dropping the path from messages, capping how many issues appear, changing the separator, hiding the 'Validation error' prefix, or localizing numbers and dates in messages
- You are on zod v4 and only need a readable string. z.prettifyError(err) is built in, costs nothing, and covers the common case. The project's own README says it would happily be decommissioned if that were best for the community, which is an unusually honest signal that this is a convenience layer, not infrastructure
- You are rendering per-field errors under form inputs. A flat concatenated string is the wrong shape; you want z.treeifyError, z.flattenError, or your form library's zod resolver, and squeezing that back out of one sentence means re-parsing your own output
- You need translated error messages. Everything here assembles English fragments with configurable separators, so real internationalization means a locale-aware zod error map instead, and this library sits at the wrong layer to help
- You have a mixed zod v3 and v4 dependency tree. The default entry point targets zod v4 and v3 users must import from zod-validation-error/v3, so a transitive dependency on the other major produces confusing types or silently unformatted errors
- You are counting dependencies. This is roughly ten kilobytes minified to concatenate issue messages and expose a couple of type guards. If your team already has an error-shaping layer, one small helper function in your own codebase does the same job with nothing to keep upgrading alongside zod
Setup reality
npm install zod-validation-error, no runtime dependencies, Node 18 or later, TypeScript 4.5 or later. The peer range is zod ^3.25.0 || ^4.0.0 and the default export path builds against zod v4, so if you are still on v3 you must import from zod-validation-error/v3 and read the separate README.v3.md, otherwise messages come out wrong or types refuse to line up. Both CommonJS require and ESM import work out of the box. The part that surprises people is that there are two independent configuration layers: createErrorMap changes how a single issue is phrased and is registered globally with zod.config({ customError: createErrorMap() }), while createMessageBuilder changes how several issue messages are joined into the final sentence and is passed per call to fromError. Registering the error map is process-wide, so it also rewrites messages produced by schemas inside other libraries you did not write. Two defaults catch people out: forceTitleCase is on, so your own custom messages get title-cased, and reportInput defaults to reporting the received type in the message, which can leak more about the payload than you intended.
Patterns
Turn a thrown ZodError into a readable messageformat-caught-error
import { z } from 'zod';
import { fromError } from 'zod-validation-error';
const schema = z.object({
id: z.int().positive(),
email: z.email(),
});
try {
schema.parse({ id: 1, email: 'coyote@acme' });
} catch (err) {
const validationError = fromError(err);
console.log(validationError.toString());
// Validation error: Invalid email at "email"
}fromError accepts unknown, so it is safe on a catch binding; fromZodError requires an actual ZodError and throws if you hand it something else. The result is a real Error, so you can rethrow it.
Make zod phrase individual issues in plain Englishregister-error-map
import { z } from 'zod';
import { createErrorMap } from 'zod-validation-error';
z.config({
customError: createErrorMap({
displayInvalidFormatDetails: false,
reportInput: false,
maxAllowedValuesToDisplay: 5,
}),
});z.config is global for the process, so this also rewrites messages from schemas inside third-party packages. reportInput defaults to 'type' and puts the received value's type into the message, so set it to false if error text is user facing.
Format without try/catch using safeParsesafeparse-instead-of-throw
import { fromZodError } from 'zod-validation-error';
const result = schema.safeParse(input);
if (!result.success) {
const error = fromZodError(result.error);
return { status: 400, body: { message: error.message } };
}
const data = result.data;safeParse avoids exception control flow and narrows result.data for you on success. result.error is a ZodError, so fromZodError is the precise call here rather than the permissive fromError.
Control how issues are joined into one sentencecustomize-final-message
import { fromError } from 'zod-validation-error';
const error = fromError(err, {
maxIssuesInMessage: 3,
issueSeparator: ' | ',
prefix: undefined,
includePath: false,
forceTitleCase: false,
});These are createMessageBuilder options passed inline; they shape the joined string, not the wording of each issue, which is the error map's job. Pass prefix: undefined to drop the 'Validation error: ' lead entirely, and note forceTitleCase defaults to true and will capitalize your own custom messages.
Build one formatter and reuse it everywherereusable-message-builder
import { createMessageBuilder, fromError } from 'zod-validation-error';
export const apiMessages = createMessageBuilder({
maxIssuesInMessage: 5,
includePath: true,
prefix: undefined,
});
export const toApiError = (err: unknown) =>
fromError(err, { messageBuilder: apiMessages });Creating the builder once keeps formatting identical across handlers and avoids rebuilding it per request. It is a plain function of issues to string, so you can substitute your own implementation with the same signature.
Return 400 for bad input and 500 for real failuresdistinguish-error-types
import { isValidationErrorLike } from 'zod-validation-error';
try {
await handler(req);
} catch (err) {
if (isValidationErrorLike(err)) {
res.status(400).json({ message: (err as Error).message });
} else {
res.status(500).json({ message: 'Internal error' });
}
}Prefer isValidationErrorLike over isValidationError. The strict version uses instanceof, which returns false when a dependency bundles its own copy of this package and the prototype differs; the Like version checks structure instead.
Log the structured issues while showing the sentencekeep-original-issues
import { fromError } from 'zod-validation-error';
const error = fromError(err);
logger.warn({
msg: error.message,
issues: error.details.map((i) => ({
path: i.path.join('.'),
code: i.code,
})),
});error.details is the untouched zod issue array, so nothing is lost by formatting. The original ZodError is also on error.cause when the library was given one.
Format one issue rather than the whole errorsingle-issue
import { fromZodIssue } from 'zod-validation-error';
const result = schema.safeParse(input);
if (!result.success) {
const perField = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: fromZodIssue(issue).message,
}));
}This is the closest you get to per-field errors from this library, and it costs one ValidationError object per issue. For form rendering, z.treeifyError is a better fit than mapping issues by hand.
Use the curried form in functional pipelinescurried-converter
import * as Either from 'fp-ts/Either';
import { toValidationError, ValidationError } from 'zod-validation-error';
export function parseUser(value: unknown) {
return Either.tryCatch(
() => schema.parse(value),
toValidationError(),
);
}toValidationError takes options first and returns the converter, which is the shape Either.tryCatch and similar combinators expect. Without currying you would wrap it in a lambda on every call site.
Reuse ValidationError for hand-written checksthrow-outside-zod
import { ValidationError } from 'zod-validation-error';
function parseBuffer(buf: unknown): Buffer {
if (!Buffer.isBuffer(buf)) {
throw new ValidationError('Invalid argument; expected buffer');
}
return buf;
}
// preserve the underlying failure:
// throw new ValidationError('Upstream rejected the payload', { cause: err });Doing this keeps one error class across zod and non-zod validation, so a single isValidationErrorLike check in your middleware covers both. details is empty for manually constructed errors, so guard before mapping over it.
Import the right build for zod v3zod-v3-entry-point
// zod v4 (default entry)
import { fromError } from 'zod-validation-error';
// zod v3
import { fromZodError } from 'zod-validation-error/v3';The package ships both builds and the bare specifier resolves to the v4 one. On a v3 codebase the bare import compiles but produces mismatched types and wrong output, and the v3 build has its own separate README and option set.
Check whether zod v4 already covers your casecompare-with-prettify-error
import { z } from 'zod';
const result = schema.safeParse(input);
if (!result.success) {
console.log(z.prettifyError(result.error)); // multi-line, built in
console.log(z.flattenError(result.error)); // { formErrors, fieldErrors }
}prettifyError produces a multi-line developer-facing block, not a single configurable sentence, and gives you no error class or type guards. If a log line is all you need, that is the whole answer and you can drop the dependency.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zod | npm | You are on v4 already: z.prettifyError, z.treeifyError, and z.flattenError cover most formatting needs with no extra package. |
| zod-i18n-map | npm | Your validation messages need to be translated rather than just readable, using i18next locale files behind a zod error map. |
| @hookform/resolvers | npm | The errors are going into a React form and you need them keyed by field, not flattened into one sentence. |