If you've shipped a form with Zod, you've written this code at least once:
const result = Schema.safeParse(input);
if (!result.success) {
const errors = result.error.issues.reduce((acc, issue) => {
acc[issue.path.join(".")] = issue.message;
return acc;
}, {} as Record<string, string>);
}
And then you've stared at "Invalid string: expected email" next to a form field and thought: no real user should ever see this.
That tiny translation layer — between what Zod gives you and what your UI actually needs — ends up in every project. So I packaged it.
It's called friendly-zod. It's free, MIT, ~2 KB minzipped, zero runtime dependencies, and works with both Zod 3 and Zod 4.
npm install friendly-zod zod
Here's what it does, why it exists, and how to use it whether you're writing your first form or maintaining a design system used by twelve product teams.
The problem in one screenshot
Zod is great at telling you, the developer, what's wrong:
{
code: "invalid_string",
validation: "email",
path: ["email"],
message: "Invalid email"
}
That's perfect for logs. It's terrible next to an input field. Users don't read paths. They don't know what "invalid_string" means. They want: "Email is not a valid email address."
So every codebase ends up with a formatZodError helper. Then a slightly different one in the next project. Then someone needs to override the message for a specific field. Then someone needs React. Then the helper has fifty lines and no tests.
friendly-zod is that helper, written once, tested, typed, and shared.
The 30-second version
import { z } from "zod";
import { humanize } from "friendly-zod";
const Schema = z.object({
email: z.string().email(),
age: z.number().min(18),
firstName: z.string().min(2),
});
const result = humanize(
Schema.safeParse({ email: "x", age: 15, firstName: "" }),
);
// {
// success: false,
// data: null,
// errors: {
// email: "Email is not a valid email address",
// age: "Age must be at least 18",
// firstName: "First name is required"
// },
// firstError: "Email is not a valid email address"
// }
That's the whole API on the happy path. You wrap safeParse, you get back a flat object keyed by field, with messages you can render directly.
On a valid input you get { success: true, data, errors: null, firstError: null } — same shape, different branch. No if (result.success) ladder that forks your component logic.
Why "friendly"?
Three things make the messages feel like a person wrote them:
-
Field names are derived from paths.
firstNamebecomes"First name".address.citybecomes"City".tags.0becomes"Tags"(numeric segments get dropped, because "Tags 0 must be a string" is what a robot would write). - The message reads like English. Not "Invalid string: expected email at path 'email'." Just "Email is not a valid email address."
- You can override anything. Acronyms, domain language, ESL audiences — all yours to control.
The defaults aim to be good enough that you ship without touching them. The overrides are there for when "good enough" isn't.
For React folks: there's a hook
If you're using React, there's a sub-import that does the state plumbing for you:
import { z } from "zod";
import { useFriendlyErrors } from "friendly-zod/react";
const Schema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
function SignupForm() {
const { errors, validate, clearErrors } = useFriendlyErrors(Schema);
const onSubmit = (data: unknown) => {
if (!validate(data)) return;
// TypeScript now knows `data` is z.infer<typeof Schema>
submitToApi(data);
};
return (
<form>
<input name="email" aria-invalid={!!errors?.email} />
{errors?.email && <span className="error">{errors.email}</span>}
<input name="age" type="number" aria-invalid={!!errors?.age} />
{errors?.age && <span className="error">{errors.age}</span>}
</form>
);
}
Two details worth knowing here:
-
validateis a TypeScript type guard. Inside theif, yourdatais narrowed to the schema's inferred type. No casting. - The React entry is a separate export. If you don't use it, it doesn't ship to your bundle. Tree-shaking does the rest.
If you're not on React — Vue, Svelte, Solid, vanilla, Node API handlers — the core humanize function is framework-agnostic. It just returns a plain object.
Customising for the real world
Defaults are fine until you hit your first acronym. "Kra pin" is not how anyone writes "KRA PIN." So:
humanize(result, {
fieldNames: {
kraPin: "KRA PIN",
email: "Your email address",
},
messages: {
invalid_format: ({ field, issue }) =>
issue.format === "email" ? `${field} doesn't look right` : undefined,
too_small: ({ field, issue }) =>
issue.type === "string"
? `${field} needs at least ${issue.minimum} characters`
: undefined,
},
});
The pattern I like most here: a handler that returns undefined falls through to the default. So you override only the cases you care about. You don't have to re-implement everything to tweak one line.
You can build a single humanize wrapper for your whole product, version it, share it across teams, and let each team pass in their own field labels. The library stays small because the customization is data, not code.
A few things that are intentionally not in scope
I want to be honest about what this library doesn't do, because most "look at my package" articles aren't:
- It doesn't replace your form library. Use it with React Hook Form, Formik, TanStack Form, or hand-rolled state. It's a translation layer, not a form engine.
- It doesn't do i18n for you. It gives you the seams. You provide the translations.
- It doesn't change your schemas. Your Zod schemas stay the same. You wrap the result, not the schema.
-
It won't throw at you. Public functions return data or
nullinstead of throwing. Safe to call on any input.
Small surface area. One job. Done well.
Performance and bundle size
The core export is roughly 2 KB minzipped with no dependencies. The React export is a thin wrapper on top, only loaded if you import it. The package is marked sideEffects: false, so unused exports get tree-shaken on any modern bundler (Vite, esbuild, Rollup, webpack 5+, Turbopack).
It runs in Node, browsers, Next.js, Cloudflare Workers, Deno, and Bun. ESM and CJS builds both ship.
FAQ
Does it support Zod 4?
Yes. The peer dependency accepts Zod >=3, and the library is tested against both major versions.
Does it work in server components / on the edge?
Yes. No DOM, no Node-only APIs. Cloudflare Workers, Vercel Edge, Deno Deploy — all fine.
What about nested objects and arrays?
Paths are flattened into dot notation for the keys (address.city), and array indices are dropped from the human label so the message reads naturally.
Can I use it without TypeScript?
Yes. It's published with types, but plain JS works the same.
Why not just contribute to Zod itself?
Zod's defaults are correct for a validation library — they're precise, machine-readable, version-stable. UI-facing messages are a different concern with different requirements (tone, branding, i18n). Keeping them in a thin layer outside the core feels right.
Try it
npm install friendly-zod zod
# or
yarn add friendly-zod zod
# or
pnpm add friendly-zod zod
Collins87mbathi
/
friendly-zod
Human-readable error messages for Zod schemas. Drop-in, typed, Zod 3 + 4 compatible, with an optional React hook. Zero runtime dependencies.
friendly-zod
Human-readable Zod errors. Turn the cryptic stuff Zod gives you into messages you'd actually show a user.
Every Zod project ends up with the same translation layer between programmer-readable issues and what goes in the form. This is that layer.
Install
npm install friendly-zod zod
yarn add friendly-zod zod
pnpm add friendly-zod zod
Works with Zod 3 and Zod 4. No runtime dependencies. Runs in Node, browsers, Next.js, Cloudflare Workers, Deno, Bun.
A quick taste
import { z } from "zod";
import { humanize } from "friendly-zod";
const Schema = z.object({
email: z.string().email(),
age: z.number().min(18),
firstName: z.string().min(2),
});
const result = humanize(
Schema.safeParse({ email…If you ship a form this week, swap in humanize and see how it feels. If you find a Zod issue code that falls through to a generic "is invalid," open an issue or a PR — that's the part of the library that benefits most from many eyes.
And if it saves you ten minutes the next time a designer asks "why does the error say invalid_string?", that's the whole point.
Top comments (1)
Curious how you handle field-level translations when the schema is nested. We've got react-hook-form + Zod with paths like quiz.questions.3.options.0.text and the friendly messages get awkward fast. Does friendly-zod expose the path so I can map it to a localized field label?