DEV Community

Cover image for Type-Safe React Forms Without the Glue Code
Sarkis Melkonian
Sarkis Melkonian

Posted on

Type-Safe React Forms Without the Glue Code

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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')} />
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' })
Enter fullscreen mode Exit fullscreen mode

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')
  },
})
Enter fullscreen mode Exit fullscreen mode

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>}
Enter fullscreen mode Exit fullscreen mode

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: '' },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Works with React 18 and 19. MIT licensed.

Top comments (0)