I'm going to make a claim and back it up: @railway-ts/pipelines ships the smallest schema validation bundle of any library available today. Smaller than Valibot. Smaller than Zod, Yup, TypeBox, io-ts — all of them.
Don't take my word for it. This is benchmarked by schemabenchmarks.dev, a comparison site created by Open Circle — the organization behind Valibot.
That alone might be interesting. But a small schema library isn't useful if it can't handle real forms. So I built @railway-ts/use-form — a 3.6 kB React hook where the schema is the validator. No resolver. No adapter. No translation layer between your validation logic and your form state.
Here's what that actually means in practice, and why I think the current form ecosystem has a glue-code problem worth solving.
The Glue-Code Tax
The standard React form stack looks like this: pick a schema library, pick a form library, install a resolver package to connect them, then manually thread errors.fieldName?.message through your JSX. It works. It's also three packages and a bunch of wiring that has nothing to do with your actual validation logic.
The tax isn't the login form. A login form is two fields — anything works. The tax shows up when things get real: cross-field validation, async uniqueness checks, server errors that need to map back to specific fields, dynamic array fields with per-item validation. That's where the adapter pattern starts to creak.
@railway-ts/use-form sidesteps all of it. You define a schema, pass it to useForm, and you're done. The schema handles validation, type inference, and error messages. The hook handles state, touched tracking, and field binding. There's nothing in between.
Where It Actually Gets Interesting
I'm not going to walk through basic field binding — there's a live demo on StackBlitz for that. Instead, here are three things that are genuinely hard to do cleanly with other libraries.
Cross-field validation + server errors in one form
This is a signup form with password confirmation, async username checking, and server-side email validation — three different error sources that all need to surface on the right field:
const baseSchema = S.object({
username: S.required(S.chain(S.string(), S.nonEmpty("Required"), S.minLength(3, "Min 3 chars"))),
email: S.required(S.chain(S.string(), S.nonEmpty("Required"), S.email("Invalid email"))),
password: S.required(S.chain(S.string(), S.nonEmpty("Required"), S.minLength(8, "Min 8 chars"))),
confirmPassword: S.required(S.chain(S.string(), S.nonEmpty("Confirm your password"))),
});
type SignupValues = S.InferSchemaType<typeof baseSchema>;
const signupSchema = S.chain(
baseSchema,
// Cross-field: receives the whole object, targets a specific field
S.refineAt<SignupValues>("confirmPassword", (data) => data.password === data.confirmPassword, "Passwords must match"),
);
const form = useForm(signupSchema, {
initialValues: { username: "", email: "", password: "", confirmPassword: "" },
// Async per-field: debounced automatically, surfaces on the field
fieldValidators: {
username: async (value) => {
const taken = await checkUsername(value);
return taken ? "Already taken" : undefined;
},
},
onSubmit: async (values) => {
const result = await createAccount(values);
if (result.type === "EMAIL_TAKEN") {
// Server errors: same error system, keyed by field name
form.setServerErrors({ email: "Already registered" });
}
},
});
Three error layers — schema validation, async field validators, server errors — all flowing through the same getFieldError() call. No special handling per source.
One thing worth calling out: every field path is fully typed. getFieldProps("emai") is a compile error — TypeScript infers the valid field names from your schema, so you get autocomplete on every field binding and can't fat-finger a field name into a runtime bug.
Conditional validation that composes
Virtual events need a platform and meeting URL. In-person events don't. S.when lets you express this as a composable validation rule instead of imperative if blocks:
const eventSchema = S.chain(
baseEventSchema,
S.when<EventValues>(
(data) => !!data.isVirtual,
S.chain(
S.refineAt("platform", (data) => !!data.platform, "Required for virtual events"),
S.refineAt("meetingUrl", (data) => !!data.meetingUrl, "Required for virtual events"),
),
),
);
The conditional validation lives in the schema, not scattered across useEffect hooks or imperative validation functions. It composes with S.chain like everything else.
Submit returns a Result, not a prayer
This is the one that functional programming people will appreciate. You don't need an onSubmit callback. handleSubmit() returns a Result<T, ValidationError[]> that you can pattern-match on:
import * as R from "@railway-ts/pipelines/result";
const result = await form.handleSubmit(e);
R.match(result, {
ok: (values) => showSuccess(values),
err: (errors) => showValidationSummary(errors),
});
No try/catch. No silent failures. The types enforce that you've handled both paths. And because everything in the pipeline is Result-based, form submission composes cleanly with async workflows:
import { flowAsync } from "@railway-ts/pipelines/composition";
const submit = flowAsync(
validateWithSchema,
R.flatMapWith(saveToDatabase),
R.tapWith(sendConfirmationEmail),
);
flatMapWith chains async operations that return Result, tapWith runs async side effects and passes the value through. Every step either succeeds and passes data forward, or fails and short-circuits with a typed error. It's the same pattern the whole @railway-ts/pipelines library is built on.
The Full Picture
There's more that didn't fit the narrative above — dynamic array fields with arrayHelpers (push, remove, swap, per-item validation), four validation modes (live, blur, submit, mount), checkbox/select/slider bindings, onFieldChange callbacks for dependent field clearing. The StackBlitz demo covers all of it across five tabs, progressively. No install required — edit the forms live in your browser.
Why I Built This
I got tired of the adapter pattern. Schema validation and form state are the same concern — "is this data valid, and if not, where and why?" — split across multiple packages with a translation layer in between. Making the schema the single source of truth for validation, types, and error messages eliminates an entire category of wiring code.
The bundle size wasn't a goal — it was a side effect of keeping the API surface small and not pulling in dependencies. But I'll take it.
If you're interested:

Top comments (1)
Worth noting that you do not have to use our schema with the
useFormhook. It is Standard Schema compliant so you can use whatever schema you want - no resolvers required:Valibot Example