A look at how TypeScript projects came to treat schemas as the source of truth for data, why the big form libraries were designed before that happened, and what a form library looks like when it’s designed after.
I work on Formisch, so I’m obviously biased, and you should keep that in mind the whole way through. That said, most of this post is about the wider ecosystem rather than about Formisch itself, and I’ve tried to keep every claim in here something you can go verify on your own. Formisch mostly shows up at the end.
TL;DR: Over the last five years or so, TypeScript developers have pretty much settled on schema libraries like Zod, Valibot, and ArkType as the single place where they describe what their data looks like, and that agreement is now formalized in the Standard Schema spec. The major form libraries were all designed before this happened, so their schema support is an adapter layer on top of an older architecture, which works fine, but leaves you maintaining two descriptions of every form and keeping them in sync yourself. Formisch is what you get when someone designs a form library after the shift instead of before it, and I’d argue that difference matters more than any individual feature comparison.
Some history: how schemas took over
If you were validating data in a JavaScript app back in 2019, you were probably using Yup or Joi, or just writing if statements by hand. Validation was purely a runtime thing, and your TypeScript types lived in a completely separate world: you’d write an interface, you’d write the validation logic, and it was up to you to make sure they described the same data. When they drifted apart, TypeScript had no way of noticing, because it can only see one of them.
Zod’s big idea, starting in 2020, was that this duplication was the actual problem, and that you should write the schema once and derive both the runtime validator and the static type from it. That’s the entire pitch of z.infer: one description of your data, and everything else gets generated from it.
And that idea won pretty decisively. As of mid-2026, Zod sits at roughly 200 million weekly downloads on npm. Valibot showed up in 2023 with a modular, tree-shakeable version of the same premise for anyone who cares about bundle size, and ArkType pushed the syntax even closer to TypeScript itself. These libraries compete hard on ergonomics and performance, but none of them disagrees about the premise, which is usually a sign that a debate is over.
There’s an even clearer sign, though. The creators of Zod, Valibot, and ArkType got together and co-authored Standard Schema, a shared TypeScript interface (the spec itself is tiny) that lets any tool accept schemas from any compliant library without writing per-library adapters, and tRPC, TanStack Form, TanStack Router, and Hono have all adopted it. When the maintainers of three competing libraries write a joint spec and the frameworks pick it up within a year, I think it’s fair to say the ecosystem has made up its mind.
Server-side code absorbed all of this pretty quickly, too. tRPC procedures declare their inputs as schemas, API routes parse request bodies through schemas, server actions validate FormData through schemas, and plenty of projects even parse their environment variables through a schema at boot. Forms are the one part of the stack where the shift is still only halfway done, and I think the reason is mostly historical.
Form libraries were built before this
Redux Form shipped in 2015 and kept your entire form inside the Redux store. Its creator later built Final Form as a lighter, standalone take on the same job. Formik shipped in 2017, and React Hook Form shipped in 2019. All of them predate Zod’s rise, which means they were designed in a world where a “schema” was, at most, an optional Yup object you might attach for validation. It couldn’t have been the source of truth for the form, because deriving types from a schema wasn’t really a thing yet.
So in that world, a form library was basically a state manager. The hard problem forms gave you was runtime bookkeeping: what has the user typed so far, which fields have they touched, which errors should be visible right now, and how do you track all of that without re-rendering the entire page on every keystroke. RHF’s signature design decision, keeping field values outside of React state and writing to the DOM directly through refs, is a clever answer to exactly that question, and it’s a big part of why RHF got so popular.
When schemas took over, the form libraries adapted the way mature libraries usually do, by adding an integration layer. RHF has its resolver system (@hookform/resolvers), which lets you plug in Zod, Valibot, Yup, and about a dozen others, and it works well enough that millions of forms run through it every day. But it’s worth looking at what the adapter approach asks of you.
With RHF, you pass a TypeScript generic describing the form’s shape (useForm<MyFormValues>()), and then you separately pass a resolver wrapping your schema. The generic only exists at compile time and the schema is a runtime object, and for years the resolver typings were loose enough that you could declare ten fields in the generic while only validating seven in the schema, and nothing anywhere would complain.
Current resolver versions close that gap by deriving their types from the schema and flagging outright mismatches, which is a real improvement, and also sort of an admission of the whole argument: even RHF’s own type system now treats the schema as the thing your form’s types should come from. What remains is that you’re still maintaining two artifacts, a form and a schema, joined by a convention like z.infer rather than by the library’s design, and applying that convention consistently across a codebase is still your job.
TanStack Form is the newest of the mainstream options, and you can see the transition happening in its design. It dropped the generic entirely and infers your types from defaultValues instead, which removes one of the two parallel artifacts. But the types come from your defaults rather than from your schema, so if you’re validating with a schema, drift between the two is still possible, still silent, and still your job to prevent.
None of this is a design failure, and it’s worth remembering how the last big example of this played out. Redux, the state library, shipped that same year as a deliberately tiny, unopinionated core, and for years the community kept solving the same problems on top of it, hand-written action constants, switch statements, object spreads for every immutable update.
The maintainers have been clear in hindsight that none of that boilerplate was ever a required part of Redux, it came from Flux-era conventions and early docs examples that hardened into habits. So in 2019 they shipped Redux Toolkit, which took what everyone had learned and baked it into the defaults, and today the official docs mark the old low-level patterns as legacy and tell you to use RTK instead. Form libraries are sitting at the same point in that arc, and the interesting question is the same one Redux Toolkit answered. What does this category look like if you rebuild the defaults around everything we know now, with the schema shift already behind you?
What changes when the schema comes first
The way I think about it, a form library’s whole architecture follows from whatever it treats as the source of truth. In the pre-schema libraries, your components are the source of truth. You describe your form by writing fields, and the rules for each field are attached where the input is registered. That feels natural to write, but it comes with real downsides. Rules that live on one field don’t know about the others, so cross-field logic like a confirm-password check or a date range tends to drift as the form evolves. The definition of the form ends up scattered between a config object and field declarations spread across your components, which makes it harder to see and maintain as one thing. And because the rules are embedded in UI code, you can’t lift them out and run the same validation on your server.
If the schema is the source of truth, the job flips around. The schema already declares the structure, the types, the constraints, the error messages, and even the async rules, so the library’s job becomes deriving the form from that contract, wiring user input back through it, and guaranteeing that whatever comes out the other side matches. This also simplifies the form library itself. It no longer needs its own validation engine or a feature set for declaring dependencies between fields, because the schema library covers all of that. What remains is form state and the bindings to render fields, which is a much smaller job, and it’s a big part of why the core functionality of Formisch fits in about 2.5 kB. The state tracking still happens, it just stops being the product and becomes the plumbing.
And I’d argue those are two different tools that happen to share a category name. One of them answers “how do I manage input state in my components,” and the other answers “how do I get data from a user that’s guaranteed to match a contract I wrote once and can reuse on my server.” It’s also why I think the endless “which form library is fastest” comparisons increasingly miss the point: speed was the axis that mattered when forms were a state management problem, and once they’re a contract problem, the axis that matters is how many descriptions of your form you’re maintaining and what happens when they disagree.
What a post-schema form library looks like
Formisch is one answer to that question, and its origin explains a lot about its design. Fabian Hiller built Modular Forms in 2022, which was a tree-shakeable form library, and then built Valibot in 2023, which is a schema library. Formisch came out of realizing those were really two halves of one project: if a single schema can drive your validation and your types, it can drive the whole form.
The entire setup is handing a Valibot schema to the form:
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email('Please enter a valid email.')),
password: v.pipe(v.string(), v.minLength(8, 'Your password is too short.')),
});
Enter fullscreen mode Exit fullscreen mode
import { Field, Form, useForm } from '@formisch/react';
export default function LoginForm() {
const loginForm = useForm({ schema: LoginSchema });
return (
<Form of={loginForm} onSubmit={(values) => {
// values is typed { email: string; password: string }, derived from the schema
}}>
<Field of={loginForm} path={['email']}>
{(field) => (
<div>
<input {...field.props} value={field.input} type="email" />
{field.errors && <div>{field.errors[0]}</div>}
</div>
)}
</Field>
{/* password field looks the same */}
<button type="submit">Login</button>
</Form>
);
}
Enter fullscreen mode Exit fullscreen mode
There’s no generic and no resolver here, because there’s nothing left for a resolver to reconcile. The field path is typechecked against the schema, so if you rename a field in the schema, every stale reference in your codebase becomes a compile error instead of a runtime surprise.
All of the validation, including async checks and cross-field rules, lives in the schema, so your components never contain validation logic, they just adapt to whatever schema you hand them. And since the schema is a plain Valibot object, the exact same one can validate the request body on your server, which finally collapses one of the oldest duplications in web development: writing your rules once for the browser and then again for the API.
One thing worth being precise about is that the schema drives the data side of the form, not the rendering. As you can see in the example, you still write the markup for every field yourself. That’s a deliberate choice, because it leaves you in full control of your UI, and it means “schema-first” doesn’t mean your form appears automatically. There are ideas about offering generated forms as an optional layer on top at some point, but today the schema defines what the form is, and you define what it looks like.
The strongest evidence that the schema really is the center of the design (and not just a marketing frame) is structural. Formisch ships native support for React, Vue, Solid, Svelte, Preact, and Qwik out of one shared core, and swaps in each framework’s own reactivity primitives at build time. That’s practical because everything that defines your form lives in a framework-agnostic object.
You could build the same thing around component-defined forms, but since their definition is scattered across framework-specific code, you’d end up reimplementing and syncing the logic for each framework instead of maintaining one shared core. Once the schema is the form, the framework becomes a rendering detail, and the architecture post goes into how that works under the hood.
When you should not use Formisch
Your RHF forms work fine. If your forms are small to moderately complex, your team already knows RHF, and you’re not fighting type drift or resolver friction, then switching probably buys you very little.
You’re committed to Zod. Formisch is built specifically on Valibot. Valibot is excellent and tiny, but if your organization has standardized on Zod everywhere, adopting Formisch means either translating schemas or running two schema libraries side by side. Zod support is planned, which should soften this constraint over time, but it isn’t there yet, and I completely understand if that’s a dealbreaker today.
You want per-field control over validation timing. TanStack Form makes per-field, per-trigger validation configuration a first-class feature. Formisch lets you configure when validation runs through its validate and revalidate options (by default it validates on submit, then switches to live feedback), but those settings apply to the whole form, which is probably fine for 95% of forms. If your product needs one field validating on every keystroke and another only on blur, TanStack’s model maps onto that more directly.
You need boring and battle-tested. Formisch reached release candidate in June 2026, and while the API is stable and the test coverage is thorough, it has a tiny fraction of RHF’s production mileage and a much smaller community answering questions at 2am. For some teams that alone settles it, and reasonably so.
And honestly, for a three-field login form, everything in this post is academic. The two-description problem is a scaling problem: it hurts at thirty fields with conditional sections and a backend that needs the same rules, and it barely registers below that.
Where this is headed
Every other layer of the TypeScript stack has already reorganized around schemas, from API layers and routers to server actions, env parsing, and even AI tool definitions, and form libraries are the last major category where the pre-schema architecture is still the incumbent and where “schema support” still usually means an adapter rather than a foundation. I don’t expect that to stay true for long, and I’d guess it resolves from both directions at once: the incumbents will keep retrofitting until the retrofit amounts to a rewrite, and libraries designed after the shift will keep growing into the space, Conform among them, making the same schema-first bet in its own way with native browser form features and a React focus.
Formisch is our bet on the second path, and the playground runs in your browser with no install if you want to poke at it for a couple of minutes and judge the premise for yourself.
Whatever you end up picking, though, I’d suggest making the first question in any form library evaluation “how many descriptions of each form will I be maintaining,” because most of the practical differences between these libraries follow from that answer.
Further reading: the Formisch architecture post on the one-core-six-frameworks design, the Standard Schema spec, and our detailed comparison of RHF, TanStack Form, and Formisch if you want the API-level differences.
This post is better because of comments from Erik Rasmussen, Edmund Hung, Justin Schroeder, and Luca Jakob. Whatever’s still wrong with it is mine. 🌚
답글 남기기