Signup Form + Zod Validation
Signup Form
Availability + Validation
| Field | Enabled | Valid | Status |
|---|---|---|---|
| yes | yes | empty | |
| password | yes | yes | empty |
| confirmPassword | no | — | requires password |
| referralCode | yes | yes | ✓ |
| companyName | no | — | business plan required |
| companySize | no | — | business plan required |
| submit | no | — | Enter a valid email address |
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.
The Availability Rules
Section titled “The Availability Rules”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.
The SSO Conditions
Section titled “The SSO Conditions”const knownDomains = { 'acme.com': 'Acme Corporation', 'globocorp.io': 'GloboCorp Industries', 'initech.net': 'Initech',}
// Derived at call time — no extra stateconst 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.
The Validation Schemas
Section titled “The Validation Schemas”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.
Composing Them
Section titled “Composing Them”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:
activeSchemabuilds a Zod object from availability. Disabled fields are excluded. Required/optional follows Umpire’s output. PassfieldSchemas.shape(not thez.object()directly).zodErrorsnormalizes Zod’s issue array into{ field, message }pairs.activeErrorsfilters 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 Render Loop
Section titled “The Render Loop”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.
What Happens When You Interact
Section titled “What Happens When You Interact”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 values — play() recommends clearing company fields. Apply resets and any stale submit or validation issues disappear with the values.
Typed a password before SSO kicked in — play() flags the now-disabled password fields as fouls and recommends clearing them.
The Boundary
Section titled “The Boundary”| Concern | Owner | Example |
|---|---|---|
| Should this field be in play? | Umpire | companyName disabled on personal plan; password disabled via SSO |
| Which submit path is valid? | Umpire anyOf | Password auth OR SSO — whichever path satisfies |
| Is this value well-formed? | Zod | email 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? | Userland | Run 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.