DEV Community

Cover image for The Glue Code Tax: A Line-by-Line Audit of Zod + react-hook-form
Sarkis M
Sarkis M

Posted on

The Glue Code Tax: A Line-by-Line Audit of Zod + react-hook-form

This is Part 1 of Railway-Oriented TypeScript. The overview established the problem: library seams produce glue code that costs you every time you touch it. Here we count it.

Zod and react-hook-form are both good at what they do. The problem isn't in either library — it's in the space between them.

Here's a registration form. Nothing exotic — username, email, password with confirmation, async username check, server-side errors. The kind of form every SaaS app has. Let's count the glue.

Want to see the difference? Try the full-stack demo in StackBlitz — the same registration form with a shared Express backend.


The Resolver — Bridging Zod to react-hook-form

import { zodResolver } from "@hookform/resolvers/zod";

const registrationSchema = z
  .object({
    username: z.string().min(3),
    email: z.email(),
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords must match",
    path: ["confirmPassword"],
  });

type RegistrationForm = z.infer<typeof registrationSchema>;

const {
  register,
  handleSubmit,
  setError,
  clearErrors,
  formState: { errors, isSubmitting },
} = useForm<RegistrationForm>({
  resolver: zodResolver(registrationSchema),
  defaultValues: { username: "", email: "", password: "", confirmPassword: "" },
});
Enter fullscreen mode Exit fullscreen mode

That zodResolver call is one line — but it pulls in @hookform/resolvers (~8.5 kB), a package whose only job is converting Zod's SafeParseReturnType into react-hook-form's FieldErrors format. It works. It's also a bridge layer you ship, and when either library changes its error shape, this is the thing that breaks first.


Async Validation — The Real Pain

Now you need to check if a username is available. Zod's .refine() can be async, but react-hook-form's resolver doesn't handle async Zod validation reliably. Most teams move the check out of the schema:

const validateUsername = async (username: string) => {
  if (username.length < 3) return;

  try {
    const res = await fetch(
      `/api/check-username?u=${encodeURIComponent(username)}`,
    );
    const { available } = await res.json();
    if (!available) {
      setError("username", {
        type: "validate",
        message: "Username is already taken",
      });
    } else {
      clearErrors("username");
    }
  } catch {
    setError("username", {
      type: "validate",
      message: "Unable to check availability",
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

The fetch and try/catch are real work — you'd write those regardless. The glue is the setError / clearErrors dance: the { type: "validate", message } shape, the imperative calls at exactly the right moments. This exists purely to inject your async check into react-hook-form's error model.

Notice what's missing: no loading state, no race condition handling. If the user types faster than the API responds, the last response to arrive wins regardless of which was sent last. That's a bug you'll introduce and debug yourself. Glue lines: ~8


Server Errors — Another Format Conversion

The server returns { email: "Email already exists" }. react-hook-form expects setError("email", { type: "server", message: "Email already exists" }):

const onSubmit = async (values: RegistrationForm) => {
  try {
    const res = await fetch("/api/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });

    if (!res.ok) {
      const serverErrors = await res.json();
      Object.entries(serverErrors).forEach(([field, message]) => {
        setError(field as keyof RegistrationForm, {
          type: "server",
          message: message as string,
        });
      });
      return;
    }

    navigate("/welcome");
  } catch {
    setError("root", {
      type: "server",
      message: "Network error. Please try again.",
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

That Object.entries(...).forEach(...) loop is pure glue. The as keyof RegistrationForm cast is a type hole — the compiler cannot verify that the server's field names match the form's. The type: "server" string is a convention you have to know and remember. Glue lines: ~6


The Real Costs

These aren't just extra lines.

They're untested. Nobody writes tests for format conversion code. It's too short, too obviously correct. Until it isn't — and then you're debugging a runtime error in code with no test coverage.

They're cast-heavy. Every as keyof RegistrationForm and message as string is a place where a library upgrade can introduce a runtime error TypeScript won't catch. Casts are promises to the compiler you're making on behalf of runtime behavior you can't verify statically.

They break on upgrades. When either library bumps a major version, the glue between them needs re-auditing. The libraries have upgrade guides. The glue doesn't.

They fragment validation logic. Part of your validation lives in the Zod schema. Part lives in the async validator. Part lives in the server error handler. There is no single place to look to understand what makes this form valid. That question now has three answers.


The Same Form, No Glue

Same features — schema validation, cross-field validation, async username check, server errors. One Result type flowing from validation through form state.

Schema + hook

import { useForm } from "@railway-ts/use-form";
import {
  chain,
  object,
  required,
  string,
  nonEmpty,
  email,
  minLength,
  refineAt,
  ROOT_ERROR_KEY,
  type InferSchemaType,
} from "@railway-ts/pipelines/schema";

const schema = chain(
  object({
    username: required(chain(string(), nonEmpty(), minLength(3))),
    email: required(chain(string(), nonEmpty(), email())),
    password: required(chain(string(), nonEmpty(), minLength(8))),
    confirmPassword: required(chain(string(), nonEmpty())),
  }),
  refineAt(
    "confirmPassword",
    (d) => d.password === d.confirmPassword,
    "Passwords must match",
  ),
);

type Registration = InferSchemaType<typeof schema>;

const form = useForm<Registration>(schema, {
  initialValues: { username: "", email: "", password: "", confirmPassword: "" },
  fieldValidators: {
    username: async (value) => {
      const { available } = await fetch(
        `/api/check-username?u=${encodeURIComponent(value)}`,
      ).then((r) => r.json());
      return available ? undefined : "Username is already taken";
    },
  },
  onSubmit: async (values) => {
    const res = await fetch("/api/register", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (!res.ok) form.setServerErrors(await res.json());
    else navigate("/welcome");
  },
});
Enter fullscreen mode Exit fullscreen mode

refineAt places the cross-field error on the confirmPassword field path as part of the schema — no separate .refine() callback with a { path: [...] } object. fieldValidators declares async checks declaratively — the hook manages loading state (form.validatingFields.username), discards stale responses, and gates the validator behind schema validation for that field. setServerErrors takes the server's { fieldName: "message" } format directly.

JSX

<form onSubmit={(e) => void form.handleSubmit(e)}>
  <input {...form.getFieldProps("username")} />
  {form.validatingFields.username && <span>Checking...</span>}
  {form.touched.username && form.errors.username && (
    <span>{form.errors.username}</span>
  )}

  <input type="email" {...form.getFieldProps("email")} />
  {form.touched.email && form.errors.email && <span>{form.errors.email}</span>}

  <input type="password" {...form.getFieldProps("password")} />
  {form.touched.password && form.errors.password && (
    <span>{form.errors.password}</span>
  )}

  <input type="password" {...form.getFieldProps("confirmPassword")} />
  {form.touched.confirmPassword && form.errors.confirmPassword && (
    <span>{form.errors.confirmPassword}</span>
  )}

  {form.errors[ROOT_ERROR_KEY] && <div>{form.errors[ROOT_ERROR_KEY]}</div>}

  <button type="submit" disabled={form.isSubmitting || form.isValidating}>
    {form.isSubmitting ? "Registering..." : "Create Account"}
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

No format conversions. No type assertions. No setError / clearErrors. Types flow from the schema through the hook into JSX without a single as cast — form.getFieldProps("usernam") is a TypeScript error.

The numbers

Zod + RHF + resolvers @railway-ts
npm packages 3 2
Format conversion lines ~25 0
Type assertions (as) 4+ 0
Files with validation logic 3–4 1
Places errors are shaped 3 (resolver, async validator, server error handler) 1 (schema)

Bundle Size

Library Size (gzip) What you get
@railway-ts/pipelines ~5.5 kB Result, Option, pipe/flow, schema validation
@railway-ts/use-form ~4.8 kB React form hook
@railway-ts total ~10.3 kB Full stack
Zod ~13 kB Schema validation only
react-hook-form ~14 kB Form state only
@hookform/resolvers ~8.5 kB Adapter layer only
Zod + RHF total ~35.5 kB Validation + form + adapter

@railway-ts sizes measured with size-limit (both gzip and brotli). Zod/RHF sizes from bundlephobia (gzip only). The gzip-to-gzip comparison: ~10.3 kB vs ~35.5 kB. Brotli is typically ~20% smaller than gzip; the brotli column is provided for projects that serve brotli-compressed assets.

The honest comparison depends on your starting point. If your codebase already uses Zod for API validation, the marginal cost of adding react-hook-form is ~22.5 kB (RHF + resolvers), not ~35.5 kB. The @railway-ts total includes both the form hook and the full pipeline/validation library. The size argument is sharpest for greenfield projects.


Tradeoffs

Coupling. Adopting this ties your validation and form state to one library ecosystem. Less than you might think — the form hook supports Standard Schema v1, so you can pass a Zod or Valibot schema directly to useForm without any resolver. The coupling is in schema definitions, not in component code.

Ecosystem size. Zod has years of community validators, blog posts, and Stack Overflow answers. react-hook-form has a DevTools extension and a rich plugin ecosystem. If you hit a weird edge case with a third-party date picker at 11pm, RHF probably has a GitHub issue about it. @railway-ts probably doesn't yet.

Migration cost. If your team has 20+ existing react-hook-form forms, this is a rewrite, not a drop-in replacement. The API surface is different — controlled vs. uncontrolled inputs, declarative field validators vs. imperative setError, a different hook return shape. Standard Schema support means you can adopt the form hook on new forms while keeping existing RHF forms untouched — a gradual migration where you never rewrite a working form unless you choose to. But don't underestimate the cost of maintaining two form systems in parallel during the transition.

Battle-testing. Zod and react-hook-form are used in thousands of production apps. @railway-ts is newer with a smaller production footprint. For risk-averse teams, proven stability matters more than API elegance.


The glue code tax isn't an argument against modular design. It's an argument that this particular seam — between Zod's error model and react-hook-form's — extracts more overhead than it returns in flexibility, for most forms, most of the time.

Links:

Next: Part 2 — Composable Async Pipelines — the same seam appears on the backend whenever Zod meets a Result type. Part 2 covers the @railway-ts/pipelines API: how Result, curried operators, and flowAsync eliminate it.

Top comments (0)