DEV Community

Cover image for I Stopped Context-Switching Between Validation, Forms, and Pipelines
Sarkis M
Sarkis M

Posted on

I Stopped Context-Switching Between Validation, Forms, and Pipelines

There's a moment every TypeScript developer knows. You're staring at a form bug — a server error that should be showing on the email field isn't appearing. You open the component. Then the submit handler. Then the server. Then the resolver. You're tracing through three separate error-shaping layers and you can't quite hold the whole thing in your head at once.

It's not that the code is wrong. It's that understanding it requires knowing four things simultaneously: how Zod formats errors, how the resolver converts them, how setError structures them, and how the server formats its response. Four mental models for one question: what makes this form invalid?

I've been in that moment a lot. It made me wonder whether the complexity was intrinsic to the problem, or just an artifact of the tools.

Want to skip the pitch? Try it live in StackBlitz — a runnable app with schema validation, async checks, server errors, and a shared Express backend.


What We've Accepted as Normal

Here's the standard stack: Zod for validation, react-hook-form for form state, @hookform/resolvers to bridge them, plus your own async validator wiring and your own server error converter. Three npm packages, a resolver adapter, and custom glue code for anything outside the happy path.

It works. That's not the point. The point is what you're carrying in your head whenever you touch it.

Every field has errors potentially living in three different places: the Zod result, the setError calls from async validation, the setError calls from server errors. The rule for which one displays is: whatever you called last. Which means you have to track call order in your head, or you'll show a stale server error after the user has already fixed their input.

Adding a new field means touching the schema, the useForm defaults, the JSX, and potentially the async validator and server error handler depending on what the field does. These aren't in one place. They're coordinated across the file.

None of this is a knock on Zod or react-hook-form. Both are excellent at what they do. But "excellent at what they do" is exactly the problem — they do different things, and you write the code that connects them.


The Same Seam on the Backend

Zod's safeParse returns SafeParseReturnType. Your pipeline expects Result. Every route that validates input starts with a conversion:

const parsed = schema.safeParse(body);
if (!parsed.success) {
  return err({
    type: "validation",
    issues: parsed.error.issues.map((i) => ({
      path: i.path.map(String),
      message: i.message,
    })),
  });
}
// now you can use parsed.data
Enter fullscreen mode Exit fullscreen mode

You write this once, extract it into a utility, and forget about it. Until the next person writes it again because they didn't know the utility existed. Or until Zod changes its error shape. Or until someone else's async step returns a different error format and now you have two error models in the same pipeline.

The seam on the backend is structurally identical to the one on the frontend: validation output doesn't naturally match error-handling input, so you write code to bridge them.


What a Unified Stack Actually Feels Like

Here's the concrete experience of adding a field. Say you have a registration form and you need to add age: must be a number, must be at least 18.

In the Zod + RHF stack, you touch:

  1. The Zod schema — add the field
  2. The useForm defaultValues — add the field
  3. The JSX — add the input and error display
  4. If age needs async or server validation — add setError/clearErrors wiring

Three of those four are pure coordination. The Zod schema is the work. The rest is keeping other layers informed.

With @railway-ts/use-form and @railway-ts/pipelines:

// schema.ts — shared between frontend and backend
const registrationSchema = 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())),
  age: required(chain(parseNumber(), min(18, "Must be at least 18"), max(120))),
});
Enter fullscreen mode Exit fullscreen mode

If the schema syntax is unfamiliar — chain, required, objectPart 1 walks through each piece. The short version: chain() composes validators left-to-right, required() marks a field as non-optional, and object() collects fields into a typed shape.

Add age to the schema. InferSchemaType propagates it to the TypeScript type. initialValues gives you a TypeScript error immediately — age is missing. form.getFieldProps("age") works without any other changes. form.errors.age works. If the server returns { age: "Age cannot be verified" }, form.setServerErrors(res.json()) handles it — no field name mapping, no as keyof FormType cast.

The JSX is still the JSX — you write the input. But nothing else changes. The schema change propagated everywhere else automatically.

That's the difference. Not fewer files. Not less JSX. Less coordination. Less working memory spent keeping layers in sync.

This assumes you want identical validation on frontend and backend — which is true for most forms. When you intentionally need them to differ (admin bypasses, progressive disclosure, different error messages for API consumers vs. UI), you'd define separate schemas. The library doesn't force sharing; it makes sharing free when you want it.


The Error Priority Problem, Solved

The context-switching that happens when a field has multiple simultaneous error sources — schema says invalid, async check says taken, server says already registered — is one of the subtlest bugs to track in a React form.

@railway-ts/use-form makes this deterministic with a fixed priority system:

Priority Source Clears when
1 (lowest) Schema validation Every validation run
2 Async field validators Field validator re-runs
3 (highest) Server errors User edits the field

You never manage this. You read form.errors.email and display it. A server error stays visible even after schema validation passes — the server is more authoritative than client-side rules. Editing the field clears the server error and hands control back to schema validation.

<input type="email" {...form.getFieldProps("email")} />;
{
  form.touched.email && form.errors.email && <span>{form.errors.email}</span>;
}
{
  /* Could be schema, async, or server error. Always the highest-priority one. */
}
Enter fullscreen mode Exit fullscreen mode

Async validation is declared, not wired:

const form = useForm<Registration>(registrationSchema, {
  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

The hook handles loading state (form.validatingFields.username), discards stale responses — the last issued request wins, not the last received — and gates the async check behind schema validation so you're not hitting the API with values that are already invalid. None of that is wiring you write.


You Also Get a Pipeline Library

This is where it goes past "better forms." The same Result type the form hook uses natively is what @railway-ts/pipelines produces on the backend — in API handlers, in ETL jobs, in any async operation that can fail.

import { flowAsync } from "@railway-ts/pipelines/composition";
import { flatMapWith, match } from "@railway-ts/pipelines/result";
import { validate, formatErrors } from "@railway-ts/pipelines/schema";
import { registrationSchema } from "./schema"; // same file the form uses

const handleRegistration = flowAsync(
  (body: unknown) => validate(body, registrationSchema),
  flatMapWith(checkEmailUnique),
  flatMapWith(createUser),
);

app.post("/api/register", async (req, res) => {
  const result = await handleRegistration(req.body);
  match(result, {
    ok: (user) => res.status(201).json({ id: user.id }),
    err: (errors) => res.status(422).json(formatErrors(errors)),
  });
});
Enter fullscreen mode Exit fullscreen mode

formatErrors converts ValidationError[] to Record<string, string>. That's the exact format form.setServerErrors() consumes. The full loop — frontend schema validation, async field checks, backend pipeline validation, server errors surfacing on the right fields — shares one schema and one error format with zero conversion between layers.

For batch processing, you get combine, combineAll, and partition — semantics that would each be a custom accumulation loop with try/catch:

const results = await Promise.all(rawRecords.map(processTransaction));

// All-or-nothing, first failure
const batchResult = combine(results);

// All-or-nothing, all failures at once
const batchResult = combineAll(results);

// Keep both sides — the ETL default
const { successes, failures } = partition(results);
Enter fullscreen mode Exit fullscreen mode

One call each, on results you already have.


What You're Not Giving Up

If you already have Zod schemas, @railway-ts/use-form accepts them directly via Standard Schema v1 — no resolver, no adapter:

import { z } from "zod";
import { useForm } from "@railway-ts/use-form";

const zodSchema = z.object({
  username: z.string().min(3),
  email: z.email(),
  password: z.string().min(8),
  age: z.coerce.number().min(18),
});

const form = useForm<z.infer<typeof zodSchema>>(zodSchema, {
  initialValues: { username: "", email: "", password: "", age: 0 },
  onSubmit: (values) => console.log(values),
});
Enter fullscreen mode Exit fullscreen mode

Full hook API. No zodResolver. No @hookform/resolvers. The migration path is: adopt the form hook now with existing Zod schemas, migrate to @railway-ts/pipelines/schema later when you want the shared full-stack loop.


The Actual Pitch

I'm not selling you on monads or railway-oriented design as a philosophy. Those are interesting, but they're not the point.

The point is: right now, your validation layer, your form state layer, and your async pipeline layer are three separate mental models you context-switch between every time you open a related file. The error shapes don't naturally align. The libraries weren't designed to share a type vocabulary. That's not a flaw in any of them — it's what happens when you compose independently excellent tools.

There's a version of this where it's one model. Schema drives types drives form state drives server communication drives pipeline validation — the same Result, the same error format, no adapters between layers.

The bundle is ~7.8 kB brotli (~10 kB gzip) for both libraries combined. For comparison, Zod + react-hook-form + resolvers is ~35.5 kB gzip. Different compression methods — but even on the same scale, the unified stack is meaningfully smaller.

What you're getting: Zod's job, react-hook-form's job, a resolver's job, and a backend pipeline library's job — in one coherent stack that shares a type vocabulary end to end.


Want the deep dives?

GitHub:

Top comments (2)

Collapse
 
matthewhou profile image
Matthew Hou

The single-schema approach is something I wish I'd adopted earlier. I spent years maintaining separate validation logic for forms vs API endpoints vs database writes and it's a maintenance nightmare.

Zod helped a lot but the real unlock was designing the schema first and deriving everything else from it. Less code, fewer bugs, and when the business logic changes you update one place instead of three.

Collapse
 
sakobume profile image
Sarkis M

That pain is real - I spent a long time in the same place. Zod gets you surprisingly far because the schema definition is shared. But in my experience, the seams that survive are in how each layer consumes the schema.

On the frontend, you still need zodResolver() to bridge Zod's output into react-hook-form's error model. Async field validation (username availability, etc.) lives entirely outside that bridge -- you're back to manual setError/clearErrors calls and managing race conditions yourself. Server errors are the same story: you shape them, map them to field names, and call setError for each one.

On the backend, safeParse() returns Zod's SafeParseReturnType, but your pipeline expects a Result (or a thrown error, or whatever your error model is). So every route starts with a conversion step — even if you extract it into a utility, it's still a format bridge that someone has to know exists.

The thing I wanted to eliminate wasn't the schema duplication - Zod already solved that - it was the adapter duplication. Three layers consuming the same schema through three different interfaces, with error formats that don't naturally align between them.

That's what the unified stack in the article does differently. The form hook takes the schema directly - no resolver. validate() on the backend returns Result directly - no conversion. And formatErrors() produces exactly the Record<string, string> that form.setServerErrors() consumes on the frontend. The error format is the same all the way through, not because you wrote a converter, but because there's nothing to convert.

The "update one place" you're describing works even better when the consumption is unified too - change the schema, and the types, the form state, the backend validation, and the error format all follow without touching any adapter code.