Every React form library makes you assemble the same puzzle: a validation library, an adapter package, a separate TypeScript interface, and then the form hook itself. Each piece is good on its own. The friction is in the wiring.
I wanted to see what happens when the schema, the types, and the form hook are designed together from the start. No adapters. No resolvers. One dependency chain where types flow from your schema definition all the way through to field prop autocomplete.
This is what I built, and how it compares to what I was doing before.
The Usual Setup
Here's a typical React Hook Form + Zod registration form. This is good code — I've written forms like this for years:
// 1. Install three packages
// npm add react-hook-form zod @hookform/resolvers
// 2. Define a Zod schema
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.coerce.number().min(18, 'Must be 18+'),
bio: z.string().optional(),
})
// 3. Extract the type
type FormValues = z.infer<typeof schema>
// 4. Wire everything together with the resolver adapter
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function RegistrationForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', age: 0, bio: '' },
})
const onSubmit = async (values: FormValues) => {
await api.register(values)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
<textarea {...register('bio')} />
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
)
}
Three packages. A resolver adapter that bridges Zod's output format to React Hook Form's error format. A separate type extraction step. It works, and RHF is a well-built library. But every form starts with this ceremony.
One Schema, No Glue
Here's the same form:
// 1. Install two packages (the form hook and its validation dependency)
// npm add @railway-ts/use-form @railway-ts/pipelines
// 2. Define a schema — this IS the validator AND the type source
import { useForm } from '@railway-ts/use-form'
import {
object, required, optional, chain,
string, nonEmpty, email, parseNumber, min,
type InferSchemaType,
} from '@railway-ts/pipelines/schema'
const schema = object({
name: required(chain(string(), nonEmpty('Name is required'))),
email: required(chain(string(), nonEmpty('Email is required'), email('Invalid email'))),
age: required(chain(parseNumber(), min(18, 'Must be 18+'))),
bio: optional(string()),
})
// 3. That's it — type is inferred, hook consumes the schema directly
type FormValues = InferSchemaType<typeof schema>
// { name: string; email: string; age: number; bio?: string }
function RegistrationForm() {
const form = useForm<FormValues>(schema, {
initialValues: { name: '', email: '', age: 0, bio: '' },
onSubmit: async (values) => {
// values is typed as FormValues — guaranteed valid
await api.register(values)
},
})
return (
<form onSubmit={(e) => void form.handleSubmit(e)}>
<input {...form.getFieldProps('name')} />
{form.touched.name && form.errors.name && (
<span>{form.errors.name}</span>
)}
<input {...form.getFieldProps('email')} />
{form.touched.email && form.errors.email && (
<span>{form.errors.email}</span>
)}
<input type="number" {...form.getFieldProps('age')} />
{form.touched.age && form.errors.age && (
<span>{form.errors.age}</span>
)}
<textarea {...form.getFieldProps('bio')} />
<button type="submit" disabled={form.isSubmitting || !form.isValid}>
Submit
</button>
</form>
)
}
No resolver. No adapter. The schema goes directly into useForm. The types flow through automatically.
When you type form.getFieldProps(' your editor autocompletes name, email, age, bio. Type form.getFieldProps('nme') and TypeScript catches it at compile time. form.errors.email is typed. form.values.age is a number. All from the same schema definition.
It's Not Just Text Inputs
The hook has bindings for native HTML form elements:
{/* Text, email, password, textarea */}
<input {...form.getFieldProps('name')} />
<textarea {...form.getFieldProps('bio')} />
{/* Select */}
<select {...form.getSelectFieldProps('country')}>
<option value="">Choose...</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
{/* Checkbox (boolean) */}
<input type="checkbox" {...form.getCheckboxProps('agreeToTerms')} />
{/* Switch (toggle — styled checkbox) */}
<input type="checkbox" {...form.getSwitchProps('notifications')} />
{/* Radio group */}
<input type="radio" {...form.getRadioGroupOptionProps('plan', 'free')} /> Free
<input type="radio" {...form.getRadioGroupOptionProps('plan', 'pro')} /> Pro
{/* Checkbox group (array of values) */}
<input type="checkbox" {...form.getCheckboxGroupOptionProps('interests', 'sports')} /> Sports
<input type="checkbox" {...form.getCheckboxGroupOptionProps('interests', 'music')} /> Music
{/* File input */}
<input type="file" {...form.getFileFieldProps('avatar')} />
{/* Range slider */}
<input type="range" {...form.getSliderProps('volume')} />
Each one returns the right id, name, value/checked, onChange, and onBlur for its element type. You spread and go.
Nested Objects — Just Use Dots
No special API for nested data. Dot notation works everywhere:
import {
object, required, chain, string, nonEmpty,
} from '@railway-ts/pipelines/schema'
const profileSchema = object({
name: required(string()),
address: required(object({
street: required(string()),
city: required(chain(string(), nonEmpty('City is required'))),
zip: required(string()),
})),
})
// In the form:
<input {...form.getFieldProps('address.city')} />
{form.touched['address.city'] && form.errors['address.city'] && (
<span>{form.errors['address.city']}</span>
)}
The autocomplete works through nesting — type 'address.' and the editor suggests street, city, zip.
Dynamic Arrays
arrayHelpers gives you typed mutation methods for lists:
const { values, push, remove, swap, getFieldProps } =
form.arrayHelpers('contacts')
{values.map((contact, i) => (
<div key={i}>
<input {...getFieldProps(i, 'name')} placeholder="Name" />
<input {...getFieldProps(i, 'email')} placeholder="Email" />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => push({ name: '', email: '' })}>
Add Contact
</button>
push, remove, insert, swap, move, replace — all type-safe, all update validation automatically.
Validation Modes
Not every form wants the same validation timing:
// Validate on every keystroke and blur (default)
useForm(schema, { initialValues, validationMode: 'live' })
// Validate only when a field loses focus
useForm(schema, { initialValues, validationMode: 'blur' })
// Validate once on mount — good for editing existing records
useForm(schema, { initialValues: existingUser, validationMode: 'mount' })
// Don't validate until submit
useForm(schema, { initialValues, validationMode: 'submit' })
Server Errors
After submission, your API might return field-level errors. Set them and they automatically clear when the user edits that field:
const form = useForm<FormValues>(schema, {
initialValues: { email: '', username: '' },
onSubmit: async (values) => {
const response = await api.register(values)
if (!response.ok) {
form.setServerErrors({
email: 'Email already exists',
username: 'Username taken',
})
return
}
router.push('/dashboard')
},
})
Server errors take priority over client validation errors. When the user changes the email field, the server error for email is cleared automatically. No manual cleanup.
Per-Field Async Validation
Some fields need their own async check — "is this username available?" — independent of the schema:
const form = useForm<FormValues>(schema, {
initialValues: { username: '', email: '' },
fieldValidators: {
username: async (value) => {
const taken = await api.checkUsername(value)
return taken ? 'Username is already taken' : undefined
},
},
})
// Show loading state while checking
{form.validatingFields.username && <span>Checking...</span>}
Field validators only run after schema validation passes for that field. Their errors are stored separately so they don't get overwritten when the schema revalidates.
Already Use Zod or Valibot?
The hook accepts any Standard Schema v1 validator. If you're already invested in Zod or Valibot, you can use them directly — no adapter:
import { z } from 'zod'
import { useForm } from '@railway-ts/use-form'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormValues = z.infer<typeof schema>
// Works directly — no resolver, no adapter
const form = useForm<FormValues>(schema, {
initialValues: { email: '', password: '' },
})
Same hook, same typed field props, same everything. The Standard Schema protocol means the hook doesn't care which validation library produced the schema.
What It's Built On
The form hook is part of a small ecosystem called @railway-ts. The validation is powered by a functional pipelines library that uses Result types — values are either Ok (valid) or Err (list of errors). Errors accumulate across all fields in a single pass instead of short-circuiting at the first failure.
You don't need to know any of that to use the form hook. But if you want composable validation pipelines, typed error handling, or pipe/flow for chaining operations, the pieces are there.
The form hook is ~3.6 kB. The full pipelines lib is ~4.2 kB. Both are tree-shakeable.
Try It
npm add @railway-ts/use-form @railway-ts/pipelines
- GitHub
- Getting Started — step-by-step from first form to arrays
- Live Demo on StackBlitz
- Recipes — Material UI, Chakra UI, testing patterns, performance tips
Works with React 18 and 19. MIT licensed.
Top comments (0)