Skip to content

Signup Form + Zod Validation

This demo composes two layers: Umpire decides which fields are available, required, fair, or due for cleanup, and Zod handles value correctness for the fields that are in play. The button state and the primary user-facing reasons come from Umpire’s relationship model; a submit handler can still run a final Zod pass in userland when you want a schema issue summary.

Try typing bob@acme.com — the form recognizes the domain as an SSO provider, flips to the business plan, fills in the company name, and disables the password fields entirely.

Assume the fieldSchemas object from the validation section below is already defined. The submit rules reuse those same field schemas through check() anywhere the logic is just “is this value well-formed?”

import { anyOf, check, disables, enabledWhen, fairWhen, requires, umpire } from '@umpire/core'
const hasValidEmail = check('email', fieldSchemas.shape.email)
const hasStrongPassword = check('password', fieldSchemas.shape.password)
const hasNumericCompanySize = check('companySize', fieldSchemas.shape.companySize)
function allowWhenSsoOr(predicate) {
return (values, conditions) => conditions.sso || predicate(values, conditions)
}
function allowWhenNotBusiness(predicate) {
return (values, conditions) => conditions.plan !== 'business' || predicate(values, conditions)
}
const signupUmp = umpire({
fields: {
email: { required: true, isEmpty: (v) => !v },
password: { required: true, isEmpty: (v) => !v },
confirmPassword: { required: true, isEmpty: (v) => !v },
referralCode: {},
companyName: { required: true, isEmpty: (v) => !v },
companySize: { required: true, isEmpty: (v) => !v },
submit: { required: true },
},
rules: [
requires('confirmPassword', 'password'),
fairWhen('confirmPassword', (confirmPassword, values) => confirmPassword === values.password, {
reason: 'Match your password exactly',
}),
enabledWhen('companyName', (_v, c) => c.plan === 'business', {
reason: 'business plan required',
}),
enabledWhen('companySize', (_v, c) => c.plan === 'business', {
reason: 'business plan required',
}),
requires('companySize', 'companyName'),
// When SSO is active, the IdP handles authentication — no password needed
disables((_v, c) => c.sso, ['password', 'confirmPassword'], {
reason: 'SSO login — no password needed',
}),
// Submit is available via EITHER path — anyOf enables it when at least one passes
anyOf(
enabledWhen('submit', hasValidEmail, {
reason: 'Enter a valid email address',
}),
enabledWhen('submit', (_v, c) => c.sso, {
reason: 'No SSO available for this domain',
}),
),
// `oneOf()` is not the right fit here because it selects field branches,
// not predicate branches. These helpers keep the path gating readable.
enabledWhen('submit', allowWhenSsoOr((v) => !!v.password), {
reason: 'Enter a password',
}),
enabledWhen('submit', allowWhenSsoOr(hasStrongPassword), {
reason: 'Use at least 8 password characters',
}),
enabledWhen('submit', allowWhenSsoOr((v) => !!v.confirmPassword), {
reason: 'Confirm your password',
}),
enabledWhen('submit', allowWhenSsoOr((v) => v.confirmPassword === v.password), {
reason: 'Passwords must match',
}),
enabledWhen('submit', allowWhenNotBusiness((v) => !!v.companyName), {
reason: 'Enter a company name',
}),
enabledWhen('submit', allowWhenNotBusiness((v) => !!v.companySize), {
reason: 'Enter company size',
}),
enabledWhen('submit', allowWhenNotBusiness(hasNumericCompanySize), {
reason: 'Company size must be a number',
}),
],
})

confirmPassword waits for password, and fairWhen() gives it a direct mismatch reason without disabling it. Company fields gate on the business plan. Company size waits for company name. The value-shape checks for email, password strength, and numeric company size are reused directly from the Zod field schemas instead of being rewritten by hand. When a field is disabled, Umpire suppresses required to false — a validation library should never complain about a field that isn’t in play.

The submit field ties the paths together. anyOf expresses OR-logic: submit is available if the standard auth path OR the SSO path is satisfied. The extra enabledWhen rules keep submit blocked until the active path has everything it needs, including password confirmation and business-only fields.

const knownDomains = {
'acme.com': 'Acme Corporation',
'globocorp.io': 'GloboCorp Industries',
'initech.net': 'Initech',
}
// Derived at call time — no extra state
const domain = email.slice(email.indexOf('@') + 1).toLowerCase()
const sso = domain in knownDomains
const availability = signupUmp.check(values, { plan, sso })

sso is a condition, not a field value. It comes from outside the form — the business logic that maps domains to SSO providers. Umpire receives it at evaluation time alongside the field values. The rules reference it; the form doesn’t own it.

When sso becomes true, disables() fires immediately: password and confirmPassword go dark. If either had a value from before SSO was detected, play() flags them as fouls and recommends clearing.

import { z } from 'zod'
const fieldSchemas = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
referralCode: z.string(),
companyName: z.string(),
companySize: z.string().regex(/^\d+$/, 'Must be a number'),
})

These are value-shape checks — is the email well-formed, is the password long enough, is company size numeric. They do not own requiredness or submit availability. That’s Umpire’s job. submit is not in this schema — it has no value to validate, only availability.

import { activeSchema, activeErrors, zodErrors } from '@umpire/zod'
const availability = signupUmp.check(values, { plan, sso })
// Build a Zod schema that only validates enabled fields.
// Enabled + required → base schema. Enabled + optional → .optional().
// Disabled → excluded entirely.
const schema = activeSchema(availability, fieldSchemas.shape)
.refine(
(data) => !data.confirmPassword || !data.password
|| data.confirmPassword === data.password,
{ message: 'Passwords do not match', path: ['confirmPassword'] },
)
const result = schema.safeParse(values)
if (!result.success) {
// Filter to enabled fields only — disabled fields produce no errors
const errors = activeErrors(availability, zodErrors(result.error))
// errors.email → 'Enter a valid email'
// errors.companyName → undefined (disabled on personal plan)
}

Three functions from @umpire/zod:

  • activeSchema builds a Zod object from availability. Disabled fields are excluded. Required/optional follows Umpire’s output. Pass fieldSchemas.shape (not the z.object() directly).
  • zodErrors normalizes Zod’s issue array into { field, message } pairs.
  • activeErrors filters those pairs to only include enabled fields.

Cross-field refinements (like password matching) chain normally on the result of activeSchema. The refinement references confirmPassword — if that field is disabled (SSO active, or no password yet), activeSchema excludes it and the refinement sees undefined, which the guard handles.

The entire form is one loop. No branching per field, no special cases for SSO, no if (field === 'password' && !sso) conditions in JSX. Every field goes through the same path:

{fieldOrder.map((field) => {
const av = availability[field]
const error = av.enabled && touched.has(field)
? av.error ?? (av.fair === false ? av.reason : undefined)
: undefined
return (
<div className={cls('field', !av.enabled && 'field--disabled')}>
<label>
{meta.label}
{av.required && <span className="required">*</span>}
</label>
<input
disabled={!av.enabled}
value={values[field]}
onChange={(e) => updateValue(field, e.currentTarget.value)}
onBlur={() => markTouched(field)}
/>
{!av.enabled && av.reason && <div className="reason">{av.reason}</div>}
{av.enabled && error && <div className="error">{error}</div>}
</div>
)
})}
<button disabled={!availability.submit.enabled}>
{sso ? 'Continue with SSO' : 'Create account'}
</button>
{!availability.submit.enabled && availability.submit.reason && (
<div className="reason">{availability.submit.reason}</div>
)}

Availability, required markers, disabled states, inline feedback, and blocked-submit reasons all come from availability. Zod just fills FieldStatus.error when a field-local validator runs, while structural feedback like password mismatch comes from Umpire’s own reason.

Personal plan — company fields are disabled by Umpire. Zod never sees them. No validation errors for fields that aren’t in play.

Business plan — company fields enable. Umpire keeps submit blocked until the company fields are filled, and Zod still checks that companySize is digits only.

Type a known domain (e.g. bob@acme.com) — SSO condition activates. Plan flips to business. Company name fills automatically. Password fields disable via disables(). anyOf switches to the SSO path — submit no longer needs a password, just an email.

Switch plans with stale valuesplay() recommends clearing company fields. Apply resets and any stale submit or validation issues disappear with the values.

Typed a password before SSO kicked inplay() flags the now-disabled password fields as fouls and recommends clearing them.

ConcernOwnerExample
Should this field be in play?UmpirecompanyName disabled on personal plan; password disabled via SSO
Which submit path is valid?Umpire anyOfPassword auth OR SSO — whichever path satisfies
Is this value well-formed?Zodemail must match a pattern
What should reset after a transition?Umpire play()Password fields flagged when SSO activates
Do we want a final submit-time schema pass?UserlandRun Zod on button click and show a summary if desired

See Composing with Validation for the full patterns, including dynamic schema building and the check() bridge for gating availability on validity.