BlogWork With Me →
Back to Blog

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:

  1. When does validation run? (on change, on blur, on submit?)
  2. What gets validated? (visible fields only? hidden ones too?)
  3. 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:

RuleWhat It Checks
requiredNon-empty (rejects undefined, null, "", [])
min / maxNumeric bounds (inclusive)
minLength / maxLengthString character count
patternRegex match with optional flags
emailFormat check with 2+ char TLD requirement
phone7-20 chars of digits, spaces, dashes, parens, +
urlValid URL via native URL() constructor
dateParseable date with optional min/max range
fileSizeFile size under a max MB threshold
fileTypeMIME type matching with wildcard support

In your schema, rules are arrays on each field:

{
"id": "age",
"type": "number",
"label": "Your Age",
"required": true,
"validation": [
{ "type": "min", "value": 13, "message": "Must be 13 or older" },
{ "type": "max", "value": 120 }
]
}

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:

const engine = createEngine(schema, {
validators: {
noProfanity: (value, allValues) => {
const bad = ["spam", "scam"];
if (bad.some(w => String(value).toLowerCase().includes(w))) {
return "Please keep it professional";
}
return undefined; // no error
},
matchesConfirm: (value, allValues) => {
if (value !== allValues.password) {
return "Passwords do not match";
}
return undefined;
},
},
});

Reference them in your schema:

{
"id": "bio",
"type": "long_text",
"label": "About You",
"validation": [
{ "type": "maxLength", "value": 500 },
{ "type": "custom", "name": "noProfanity" }
]
}

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:

{
success: false,
adapterResults: [{
adapterName: "validation",
success: false,
error: "Validation failed: 3 field(s) with errors"
}]
}

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.

{
"id": "other_reason",
"type": "short_text",
"label": "Please specify",
"required": true,
"showIf": {
"field": "reason",
"operator": "eq",
"value": "other"
}
}

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:

{
"id": "company_name",
"type": "short_text",
"label": "Company Name",
"required": {
"field": "employment_status",
"operator": "eq",
"value": "employed"
}
}

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:

type ValidationResult = {
valid: boolean;
errors: Record<string, string[]>;
firstErrorFieldId?: string;
firstErrorSectionId?: string;
};
  • errors is a map of field ID to error messages. A field can have multiple errors (e.g., too short AND wrong format).
  • firstErrorFieldId is useful for auto-scrolling or focusing the first problem field.
  • firstErrorSectionId tells 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 orchestrator
  • built-in.ts — all 12 built-in validators
  • registry.ts — custom and async validator registry
  • create-engine.ts — where validation integrates with the engine lifecycle