@liveschema/core
v1.2.4
Published
Build a typed, branching schema from any Standard Schema validators. Inferred values are a discriminated union over reachable branches; at runtime, get the ordered list of currently-reachable fields. Works for conditional forms (single-page or multi-step)
Maintainers
Readme
@liveschema/core
A typed schema for conditional forms — declare which fields appear when, in one place, and get back:
- The live list of fields to render given the current values.
- A per-branch-narrowed value type (a discriminated union — fields gated by an equality
.when()are required in their branch). - A Standard Schema validator for form libraries and backend endpoints.
Bring your own form library (React Hook Form, TanStack Form, vee-validate, plain state) and renderer. liveschema owns the branching logic; the UI stays yours.
Using React or Vue? Reach for
@liveschema/reactor@liveschema/vueinstead — they wrap this package in auseLiveSchema()hook/composable that re-derives the reachable-field set as form state changes.
Motivation
Conditional forms scatter business logic. The rules for which fields apply when get duplicated across JSX/template v-if gates, the validation schema, and any backend checks — three places that quietly drift apart. liveschema centralizes that logic in one typed schema: the fields you render, the inferred value type, and the validator all derive from it. Change a branch once, and everything follows.
That's the gap left by today's tooling:
- Survey libraries (SurveyJS, JSON-Schema-based) — branching is built in, but they own the renderer and return untyped output.
- Form libraries (React Hook Form, TanStack Form, vee-validate, …) — headless and typed, but the branching logic ends up scattered across JSX/template gates, and TypeScript can't narrow required fields per branch.
liveschema centralizes business logic into single place and allows typed end-to-end type safety.
Install
pnpm add @liveschema/core
# plus whichever Standard-Schema-compliant validator you prefer:
pnpm add zod # or: valibot, arktype, effect, ...@liveschema/core has no runtime dependency on any specific validation library. Bring your own.
Quick start
import { z } from 'zod'
import { defineSchema, reachableFields, type InferSchema } from '@liveschema/core'
const schema = defineSchema()
.field('name', z.string().min(1))
.field('animal', z.enum(['dog', 'cat']))
.when({ animal: 'dog' }, (b) => b.field('dogSize', z.enum(['small', 'large'])))
.when({ animal: 'cat' }, (b) => b.field('indoor', z.boolean()))
type Values = InferSchema<typeof schema>
// Given current values, get the live ordered list of reachable fields:
const fields = reachableFields(schema, { name: 'Ada', animal: 'dog' })
// [
// { key: 'name', schema: <standard schema>, value: 'Ada' },
// { key: 'animal', schema: <standard schema>, value: 'dog' },
// { key: 'dogSize', schema: <standard schema>, value: undefined },
// ]Compared to a Zod discriminated union
You can model the same branching as a hand-written z.discriminatedUnion. But every variant has to restate the shared fields, and the duplication compounds with each shared field and each branch:
// Plain Zod — `name` (and any other shared field) repeated in every variant
const schema = z.discriminatedUnion('animal', [
z.object({
name: z.string().min(1),
animal: z.literal('dog'),
dogSize: z.enum(['small', 'large']),
}),
z.object({ name: z.string().min(1), animal: z.literal('cat'), indoor: z.boolean() }),
])// liveschema — shared fields declared once, branches added with `.when()`
const schema = defineSchema()
.field('name', z.string().min(1))
.field('animal', z.enum(['dog', 'cat']))
.when({ animal: 'dog' }, (b) => b.field('dogSize', z.enum(['small', 'large'])))
.when({ animal: 'cat' }, (b) => b.field('indoor', z.boolean()))InferSchema<typeof schema> is the identical discriminated union (name in both branches, dogSize only on 'dog', indoor only on 'cat'). The difference is everything around the type:
- No repetition — shared fields are declared once, not restated per variant.
- Branch only where it matters — write a
.when()only for the values that add fields. Drop.when({ animal: 'cat' }, …)entirely and'cat'is still valid as the base record; adiscriminatedUnionrejects any discriminant value you don't spell out as a full variant. - Any condition, not just literals — branches can be predicates or cross-field checks (
.when((v) => …)), which a literal-discriminant union can't express at all.
API
| Method | Fires at runtime when | Effect on the inferred type |
| ---------------------------------- | -------------------------------- | ---------------------------------------------------------------- |
| .field(key, schema) | always | adds the field |
| .when({k: literal, ...}, b => …) | every listed key matches literal | new fields required in matching variants (narrows the union) |
| .whenAny([p1, p2, …], b => …) | any pattern matches | required in statically-matching variants, optional in others |
| .when((v) => boolean, b => …) | predicate is truthy | optional (TS can't introspect predicates) |
Wrap multiple subfields in an object schema to group them — the value nests:
.field('owner', z.object({ name: z.string().min(1), email: z.email() }))
// → values.owner.nameInside a narrowed branch, fields that were optional in the union become required:
function handle(v: InferSchema<typeof schema>) {
if (v.animal === 'dog') {
const size: 'small' | 'large' = v.dogSize // required, not optional
}
}Use Partial<Values> to model in-progress (partially-filled) state.
Backend validation
toStandardSchema(schema) returns a Standard Schema validator that covers the currently-reachable fields. Plug it into any framework that accepts Standard Schema:
import { toStandardSchema } from '@liveschema/core'
const standard = toStandardSchema(schema)
let result = standard['~standard'].validate(await req.json())
if (result instanceof Promise) result = await result
if (result.issues) return new Response(JSON.stringify(result.issues), { status: 422 })
// result.value is fully typed and only contains reachable fieldsThe validator rebuilds result.value from scratch each call, so abandoned-branch values (e.g. dogSize after animal switched from 'dog' to 'cat') are pruned for free — both on the backend and inside form libraries that submit through it. If you're managing raw state yourself, filter against reachableFields:
const keep = new Set(reachableFields(schema, values).map((f) => f.key))
const cleaned = Object.fromEntries(Object.entries(values).filter(([k]) => keep.has(k)))Rendering the reachable fields
Two rendering shapes work well; both are driven from the same schema.
1. Static layout, gate each field by reachability (default)
Most forms have a small, known set of fields with distinct markup per field. Lay out every field statically and gate visibility per-field on whether it's currently reachable. With raw core, build a keyed lookup from reachableFields(...) — a field is reachable exactly when it's present in the result:
import { reachableFields, enumOptions, type SchemaField } from '@liveschema/core'
const reachable = Object.fromEntries(
reachableFields(schema, values).map((f) => [f.key, f]),
) as Partial<Record<FieldKey, SchemaField<FieldKey>>>
// pseudo-JSX — one block per field, rendered only when reachable:
return (
<>
{reachable.email && <TextInput name="email" value={values.email} />}
{reachable.orderType && (
<RadioGroup name="orderType" options={enumOptions(reachable.orderType.schema) ?? []} />
)}
{reachable.leaveAtDoor && <Checkbox name="leaveAtDoor" />}
{/* ... */}
</>
)End-to-end with raw core: examples/vanilla-example/src/main.ts.
If you're using @liveschema/react / @liveschema/vue, the hook hands you this directly — a keyed reachableFields record plus an isReachableField(key) predicate to gate with (and a fields record carrying isReachable for every declared field, if you'd rather render unreachable fields as disabled than unmount them). End-to-end: examples/tanstack-form-example/src/FormPage.tsx.
2. Dynamic for-loop with a renderer map
When you have many similar fields (one-field-per-screen wizards, dense surveys) and don't need to customize each field, you can iterate reachableFields(...) and dispatch each field to a small renderer keyed by field name:
import { Fragment } from 'react'
import type { SchemaField } from '@liveschema/core'
type Renderer = (field: SchemaField<FieldKey>) => ReactNode
const renderers: Record<FieldKey, Renderer> = {
email: (f) => <TextStep path={f.key} type="email" />,
fullName: (f) => <TextStep path={f.key} />,
orderType: (f) => <RadioStep path={f.key} options={enumOptions(f.schema) ?? []} />,
leaveAtDoor: (f) => <CheckboxStep path={f.key} />,
pizzaCount: (f) => <NumberStep path={f.key} min={1} max={20} />,
// ...
}
return (
<>
{reachableFields(schema, values).map((field) => (
<Fragment key={field.key}>{renderers[field.key](field)}</Fragment>
))}
</>
)End-to-end examples:
- examples/vue-example — Vue 3 + vee-validate (single-page)
- examples/tanstack-form-example — React + TanStack Form (single-page)
API
| Export | Purpose |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| defineSchema() | Start a schema builder |
| .field(key, schema) | Declare a field (schema is any Standard Schema validator) |
| .when(pattern, branch) | Equality-gated sub-branch |
| .when(predicate, branch) | Predicate-gated sub-branch |
| .whenAny(patterns, branch) | OR-gated sub-branch |
| reachableFields(schema, values) | Ordered list of currently-reachable fields |
| declaredFields(schema, values) | Every declared field in source order, each tagged with isReachable — useful when you want to render gated fields as disabled rather than unmount them |
| validateSchema(schema, values) | { key: firstMessage } errors for reachable fields — plug straight into Formik/vee-validate/etc. validate |
| toStandardSchema(schema) | One Standard Schema validating the currently-reachable fields — for TanStack Form's onDynamic, react-hook-form's standard-schema resolver, backend request validation, etc. |
| enumOptions(schema) | Best-effort enum option list (undefined for non-enum schemas) — handy for rendering radios/selects |
| InferSchema<F> | Discriminated-union value type |
| InferField<F, K> | Type of a single field across variants |
| FlatInferSchema<F> | Flat, fully-optional view of the value type — single record indexable by any field key (for in-progress form state with libraries that want one shape, e.g. RHF) |
| SchemaKeys<F> | Union of every field key across every variant |
| SchemaField<K> | { key, schema, value } returned by reachableFields |
| DeclaredField<K> | { key, schema, isReachable, value } returned by declaredFields |
| SchemaErrors<F> | Return shape of validateSchema — Partial<Record<SchemaKeys<F>, string>> |
What this package is not
- Not a form library — bring your own (TanStack Form, vee-validate, React Hook Form, plain state). The package gives you the reachable-field list; you keep the values.
- Not a UI library — render however you want, keyed by
field.key. - Doesn't track navigation or phase (e.g. "fill" vs "review", current-step index) — that's all in the consumer.
- Doesn't route validation errors into form-library field state — the consumer does that with the issues from
field.schema['~standard'].validate(values[field.key]).
