friendly-zod
v1.0.0
Published
Human-readable Zod errors. Turn invalid_string + email at path 'age' into 'Age is not a valid email address' — with full customisation and a React hook.
Maintainers
Readme
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 zodWorks 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: "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"
// }When the input is valid you get { success: true, data, errors: null, firstError: null } — same shape, different branch.
React hook
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; // errors state is now populated
// data is type-narrowed to 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>
);
}validate is a TypeScript type guard — when it returns true, the input is narrowed to your schema's inferred type.
Customising messages
Override the field name, the message for a given issue code, or both:
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,
},
});A handler returning undefined falls through to the default, so you only override what you want.
Field names
Paths are turned into labels automatically:
| Path | Becomes |
|---|---|
| firstName | "First name" |
| kra_pin | "Kra pin" |
| address.city | "City" |
| tags.0 | "Tags" (numeric segments are dropped) |
Use fieldNames for things automatic prettifying gets wrong — acronyms like "KRA PIN" or "URL", for instance.
A few things worth knowing
- No schema changes needed. Just wrap your existing
safeParseresult. - Works with both Zod 3 and Zod 4 — the peer dep accepts either.
- The React hook is a separate sub-import. If you don't use React, it doesn't ship to your bundle.
- Public functions return data or null instead of throwing. Safe to call on any input.
Contributing
PRs welcome — see CONTRIBUTING.md for setup and conventions. The most useful contributions right now: better default message wording and handlers for Zod issue codes that currently fall through to "is invalid".
License
MIT © Collins Mbathi
