Type-Safe React Forms Without the Glue Code
Source: Dev.to
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/zod
// 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
// 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({
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 {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
<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 using a library that combines the schema, type inference, and form hook:
// 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
// { name: string; email: string; age: number; bio?: string }
function RegistrationForm() {
const form = useForm(schema, {
initialValues: { name: '', email: '', age: 0, bio: '' },
onSubmit: async (values) => {
// `values` is typed as FormValues — guaranteed valid
await api.register(values)
},
})
return (
<form onSubmit={form.handleSubmit}>
<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 {...form.getFieldProps('age')} />
{form.touched.age && form.errors.age && <span>{form.errors.age}</span>}
<button type="submit">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. Typing form.getFieldProps('nme') is caught by TypeScript 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 provides bindings for native HTML form elements:
{/* Text, email, password, textarea */}
<input {...form.getFieldProps('email')} type="email" />
{/* Select */}
<select {...form.getFieldProps('country')}>
<option value="">Choose…</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
{/* Checkbox (boolean) */}
<input {...form.getFieldProps('agreeTerms')} type="checkbox" />
{/* Switch (toggle — styled checkbox) */}
<input {...form.getFieldProps('notifications')} type="checkbox" />
{/* Radio group */}
<input {...form.getFieldProps('plan')} type="radio" value="free" /> Free
<input {...form.getFieldProps('plan')} type="radio" value="pro" /> Pro
{/* Checkbox group (array of values) */}
<input {...form.getFieldProps('interests[0]')} type="checkbox" value="sports" /> Sports
<input {...form.getFieldProps('interests[1]')} type="checkbox" value="music" /> Music
{/* File input */}
<input {...form.getFieldProps('avatar')} type="file" />
{/* Range slider */}
<input {...form.getFieldProps('volume')} type="range" min="0" max="100" />
Each helper returns the correct id, name, value/checked, and onChange props for its element type, keeping the API ergonomic and type‑safe.
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:
{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(`contacts[${i}].name`)} />
<input {...getFieldProps(`contacts[${i}].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 and automatically update validation.
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(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 needed.
Per‑Field Async Validation
Some fields need their own async check — e.g., “is this username available?” — independent of the schema:
const form = useForm(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 aren’t 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 required:
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
// Works directly — no resolver, no adapter
const form = useForm(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. 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.
- Form hook size: ~3.6 kB
- Full pipelines library size: ~4.2 kB (both tree‑shakable)
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.