DEV Community

Cover image for I got tired of translating Zod errors. So I built friendly-zod.
Collins Mbathi
Collins Mbathi

Posted on

I got tired of translating Zod errors. So I built friendly-zod.

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>);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
// }
Enter fullscreen mode Exit fullscreen mode

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:

  1. Field names are derived from paths. firstName becomes "First name". address.city becomes "City". tags.0 becomes "Tags" (numeric segments get dropped, because "Tags 0 must be a string" is what a robot would write).
  2. The message reads like English. Not "Invalid string: expected email at path 'email'." Just "Email is not a valid email address."
  3. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two details worth knowing here:

  • validate is a TypeScript type guard. Inside the if, your data is 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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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 null instead 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
Enter fullscreen mode Exit fullscreen mode

GitHub logo 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.

CI npm bundle size license

Install

npm install friendly-zod zod
yarn add friendly-zod zod
pnpm add friendly-zod zod
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
arvavit profile image
Vadym Arnaut

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?