How FieldCraft's Validation Pipeline Works Under the Hood
Why Validation Matters in Schema-Driven Forms
In code-driven forms, validation logic lives right next to the field. You control when it runs, what it checks, and how errors surface. In schema-driven systems, that logic has to be expressed declaratively and executed by the engine at the right times.
FieldCraft's validation pipeline solves three problems:
- When does validation run? (on change, on blur, on submit?)
- What gets validated? (visible fields only? hidden ones too?)
- How do custom rules integrate without breaking the pipeline?
Let's walk through the architecture.
The Three Layers
Schema Rules Custom Validators Submit Guard
(built-in) (your code) (engine-level)
| | |
v v v
┌─────────┐ ┌─────────────┐ ┌──────────────┐
│ required │ │ customFn() │ │ validateAll()│
│ min/max │ │ wrapped in │ │ every visible│
│ email │──────│ try/catch │──────│ field across │
│ pattern │ │ per field │ │ all sections │
│ phone │ └─────────────┘ └──────────────┘
│ url/date │ |
│ fileSize │ Returns false?
│ fileType │ Submit blocked.
└─────────┘ Errors surfaced.
Layer 1: Built-in Rules
FieldCraft ships 12 built-in validators that cover the most common cases:
| Rule | What It Checks |
|---|---|
required | Non-empty (rejects undefined, null, "", []) |
min / max | Numeric bounds (inclusive) |
minLength / maxLength | String character count |
pattern | Regex match with optional flags |
email | Format check with 2+ char TLD requirement |
phone | 7-20 chars of digits, spaces, dashes, parens, + |
url | Valid URL via native URL() constructor |
date | Parseable date with optional min/max range |
fileSize | File size under a max MB threshold |
fileType | MIME type matching with wildcard support |
In your schema, rules are arrays on each field:
Key design decision: all validators except required pass empty values. If a field is optional, an empty value is always valid. The required rule is the only one that treats emptiness as an error. This means you don't get spurious "invalid email" errors on a field the user hasn't touched yet.
Layer 2: Custom Validators
When built-in rules aren't enough, register named validators at engine creation:
Reference them in your schema:
Error isolation: every custom validator call is wrapped in try/catch. If your validator throws, the pipeline doesn't crash. Instead, the field gets an error message like "Custom validator 'noProfanity' threw: Cannot read property 'x' of undefined". The rest of the form keeps working.
Missing validators: if a schema references a validator name that wasn't registered, it's silently skipped in production. In development mode (NODE_ENV !== "production"), a console warning is logged.
Layer 3: The Submit Guard
When the user clicks submit, the engine runs validateAll() — a full sweep of every visible field across every visible section. This is the gatekeeper:
engine.submit()
|
v
validateAll(schema, values)
|
├── For each visible section:
│ ├── For each visible field:
│ │ ├── Skip structural fields (info_block, divider, etc.)
│ │ ├── Run required check (boolean or conditional)
│ │ ├── If required fails → stop, return error
│ │ ├── If empty and not required → skip remaining rules
│ │ └── Run all validation rules in order
│ └── Collect errors with firstErrorFieldId
└── Return ValidationResult
|
├── valid: true → build FormResponse → run adapters
└── valid: false → return errors, block submission
If validation fails, submit() returns immediately with:
No network requests. No adapter calls. The submission never leaves the client.
When Validation Runs
FieldCraft validates at three different moments, each serving a different purpose:
1. On Value Change (setValue)
When a field value changes, the engine immediately validates that single field:
setValue("email", "bad")
→ validateField(emailQuestion, "bad", allValues)
→ errors: ["Please enter a valid email address"]
→ state.errors.email = ["Please enter a valid email address"]
→ subscribers notified
This gives instant feedback as the user types. But there's a catch — the error only appears if the field has been touched (the user has interacted with it). This prevents showing errors on pristine fields.
2. On Touch/Blur (touchField)
When a field loses focus, it's marked as touched and validated:
touchField("email")
→ state.touched.email = true
→ validateField(emailQuestion, currentValue, allValues)
→ errors surfaced in UI
This is the "validate on blur" pattern. The field shows clean until the user interacts and moves away.
3. On Submit Attempt
When submit() is called or setSubmitAttempted() fires, all visible fields are revalidated at once via revalidateAllVisibleFields(). This catches fields the user may have skipped. After this point, all errors are visible regardless of touch state.
Visibility-Aware Validation
A critical detail: hidden fields are never validated. If a field has a showIf condition that evaluates to false, the validation pipeline skips it entirely.
If reason is not "other", this field is invisible and its required rule is ignored. This means your validation and your conditional logic always agree — you never get an error on a field the user can't see.
The same applies at the section level. If an entire section is hidden by a showIf condition, all fields in that section are skipped during validateAll().
Conditional Required
The required property supports more than true/false. It can be a full condition expression:
The engine evaluates this condition against current form values on every validation pass. The field is only required when the condition matches. This is evaluated by the same condition evaluator that handles showIf, so all 16 operators are available.
Structural Fields Are Exempt
Non-input field types — section_header, info_block, page_break, divider, spacer, welcome-screen, thank-you-screen, rich-text, image, video — are explicitly skipped by the validation pipeline. They don't hold values, so they can't have errors.
If your schema accidentally includes required: true on an info_block, the schema validator catches this at engine creation time and throws a FormEngineSchemaError before the form ever renders.
The ValidationResult Shape
Every validation method returns the same structure:
errorsis a map of field ID to error messages. A field can have multiple errors (e.g., too short AND wrong format).firstErrorFieldIdis useful for auto-scrolling or focusing the first problem field.firstErrorSectionIdtells multi-step forms which section to navigate to.
Putting It All Together
Here's the full lifecycle of a form value from input to submission:
User types → setValue("email", "j@x.c")
├── 1. Update state.values
├── 2. Mark state.touched.email = true
├── 3. validateField → ["Please enter a valid email address"]
├── 4. Update state.errors.email
├── 5. Recompute calculated fields (if dependent)
├── 6. Recompute scores (if scoring field)
├── 7. Recompute derived state (navigation, progress)
└── 8. Notify all subscribers
User fixes → setValue("email", "jane@example.com")
├── Same pipeline
└── state.errors.email = [] (cleared)
User clicks Submit → engine.submit()
├── setSubmitAttempted() → revalidate ALL visible fields
├── validateAll() → { valid: true, errors: {} }
├── Build FormResponse
├── Run submission pipeline (adapters or onSubmit)
├── Clear draft on success
└── Return SubmitResult
The key insight is that validation is incremental during editing (one field at a time for performance) but comprehensive at submission (full sweep for correctness).
What's Next
This covers synchronous validation. Async validators (for server-side checks like "is this email already registered?") are registered separately and run outside the synchronous pipeline. We'll cover that in a future post.
If you want to explore the source, the relevant files are:
validation-runner.ts— the pipeline orchestratorbuilt-in.ts— all 12 built-in validatorsregistry.ts— custom and async validator registrycreate-engine.ts— where validation integrates with the engine lifecycle