Introduction

Describe a form once as a typed object, render it as a React component.

formdef turns a typed config into a form built on TanStack Form. You write which field gets which widget, in which order; formdef renders it, and TanStack does the rest: values, validation, arrays, submission.

import { , type  } from '@formdef/core'
import { createFormHookContexts } from '@tanstack/react-form'
import {  } from 'zod'

const { ,  } = createFormHookContexts()

const  = ({ ,  }: <string> & { : string }) => (
  <label>
    {}
    <input value={.state.} onChange={() => .(..)} />
  </label>
)

const { createForm } = ({ , , : { :  } })

export const  = createForm({
  : .({ : .() }),
  : { : '' },
  fields: { email: { : 'text', : { : 'Email' } } },
})

<SignIn /> renders a <form> with the email field, validates it with the schema and submits through TanStack.

What the compiler checks

A widget is checked against the value it will receive. Point a checkbox, which serves a boolean, at a string field, and the config does not compile:

export const  = createForm({
  : .({ : .(), : .() }),
  : { : '', : false },
  fields: {
    : { widget: 'checkbox', props: { : 'Email' } },
Type '{ label: "Email"; }' is not assignable to type 'never'.
Type 'string' is not assignable to type 'never'.
remember: { : 'checkbox', : { : 'Remember me' } }, }, })

The same check covers the props a widget receives, the names a layout writes and the default values. All of it happens at compile time; nothing of it runs in the browser.

formdef adds, it never takes away

Every option TanStack Form takes still reaches it, for the form and for each field. When a form outgrows one call, you take over the <form> element and keep the config you wrote; when formdef does not help, you write plain TanStack next to it. Ways in shows the three.

Next