Validation
Built-in field validators and custom validation rules
Stream UI includes a declarative validation system with 14 built-in validators, cross-field validation, and conditional rules. Validators run client-side and integrate with the useFieldValidation() hook for real-time feedback.
Validation Structure
Each field's validation is configured with a ValidationConfig object containing an array of checks and a timing strategy.
{
"validation": {
"checks": [
{ "validator": "required", "message": "Email is required" },
{ "validator": "email", "message": "Enter a valid email address" }
],
"validateOn": "change"
}
}Prop
Type
ValidationCheck
Each check references a named validator (built-in or custom), a user-facing error message, optional arguments, and an optional enabled condition.
Prop
Type
Built-in Validators
All built-in validators skip validation when the value is empty (null, undefined, empty string, or empty array), except required and requiredIf. This means optional fields only validate when the user has entered something.
required
Value must be non-null, non-undefined, non-empty string, and non-empty array.
{ "validator": "required", "message": "This field is required" }Basic email format check (user@domain.tld).
{ "validator": "email", "message": "Enter a valid email address" }minLength
String or array length must be at least args.min.
{ "validator": "minLength", "message": "Must be at least 8 characters", "args": { "min": 8 } }maxLength
String or array length must be at most args.max.
{ "validator": "maxLength", "message": "Cannot exceed 100 characters", "args": { "max": 100 } }pattern
Value must match the regex provided in args.pattern.
{ "validator": "pattern", "message": "Only letters and numbers allowed", "args": { "pattern": "^[a-zA-Z0-9]+$" } }min
Numeric value must be greater than or equal to args.min.
{ "validator": "min", "message": "Must be at least 18", "args": { "min": 18 } }max
Numeric value must be less than or equal to args.max.
{ "validator": "max", "message": "Must be 100 or less", "args": { "max": 100 } }numeric
Value must be a valid number (number type, or a string parseable as a number).
{ "validator": "numeric", "message": "Must be a number" }url
Value must match the URL pattern (protocol://host).
{ "validator": "url", "message": "Enter a valid URL" }matches
Value must equal the value of another field at args.path. Useful for confirm-password fields.
{ "validator": "matches", "message": "Passwords do not match", "args": { "path": "/password" } }equalTo
Value must strictly equal args.value.
{ "validator": "equalTo", "message": "Must agree to terms", "args": { "value": true } }lessThan
Numeric value must be less than the value at args.path. Useful for range validation (e.g., start date before end date).
{ "validator": "lessThan", "message": "Start must be less than end", "args": { "path": "/endValue" } }greaterThan
Numeric value must be greater than the value at args.path.
{ "validator": "greaterThan", "message": "Must be greater than minimum", "args": { "path": "/minValue" } }requiredIf
Value is required only when the field at args.path is truthy. When the condition field is falsy, this check always passes.
{ "validator": "requiredIf", "message": "Address is required for shipping", "args": { "path": "/needsShipping" } }Validation Timing
The validateOn property controls when validation runs:
| Value | Behavior |
|---|---|
"change" | Validates on every state change after the field is first touched (default) |
"blur" | Validates when the field loses focus (via the touch() callback) |
"submit" | Validates only when validate() is called manually |
Conditional Validation
Use the enabled field on a check to make it conditional. It accepts any VisibilityCondition value:
{
"checks": [
{
"validator": "required",
"message": "Company name is required for business accounts",
"enabled": { "$state": "/accountType", "eq": "business" }
}
]
}When enabled evaluates to false, the check is skipped entirely (always passes). When omitted, the check always runs.
useFieldValidation() Hook
In React, the useFieldValidation() hook connects validation to a bound state path.
import { useFieldValidation } from "@prototyperai/stream-ui"
function EmailField() {
const { errors, validate, touch, clear } = useFieldValidation("/form/email", {
checks: [
{ validator: "required", message: "Email is required" },
{ validator: "email", message: "Enter a valid email" },
],
validateOn: "change",
})
return (
<div>
<input onBlur={() => touch()} />
{errors.map((err) => (
<p key={err} className="text-red-500 text-sm">{err}</p>
))}
</div>
)
}Prop
Type
The hook automatically re-validates on state changes when the field has been touched (for "change" mode). For "blur" mode, it validates when touch() is called. For "submit" mode, call validate() explicitly when the form is submitted.
Complete Form Example
A registration form spec with multiple validators, cross-field matching, and conditional required fields:
{
"root": "form",
"elements": {
"form": {
"type": "Card",
"props": {},
"children": ["emailField", "passwordField", "confirmField", "typeField", "companyField", "submitBtn"]
},
"emailField": {
"type": "Input",
"props": {
"label": "Email",
"value": { "$bindState": "/form/email" },
"validation": {
"checks": [
{ "validator": "required", "message": "Email is required" },
{ "validator": "email", "message": "Enter a valid email" }
],
"validateOn": "blur"
}
}
},
"passwordField": {
"type": "Input",
"props": {
"label": "Password",
"type": "password",
"value": { "$bindState": "/form/password" },
"validation": {
"checks": [
{ "validator": "required", "message": "Password is required" },
{ "validator": "minLength", "message": "At least 8 characters", "args": { "min": 8 } },
{ "validator": "pattern", "message": "Must contain a number", "args": { "pattern": "\\d" } }
]
}
}
},
"confirmField": {
"type": "Input",
"props": {
"label": "Confirm Password",
"type": "password",
"value": { "$bindState": "/form/confirmPassword" },
"validation": {
"checks": [
{ "validator": "required", "message": "Please confirm your password" },
{ "validator": "matches", "message": "Passwords do not match", "args": { "path": "/form/password" } }
]
}
}
},
"typeField": {
"type": "Select",
"props": {
"label": "Account Type",
"value": { "$bindState": "/form/accountType" },
"options": ["personal", "business"]
}
},
"companyField": {
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"validation": {
"checks": [
{
"validator": "requiredIf",
"message": "Required for business accounts",
"args": { "path": "/form/accountType" },
"enabled": { "$state": "/form/accountType", "eq": "business" }
}
]
}
}
},
"submitBtn": {
"type": "Button",
"props": { "label": "Register" },
"on": { "press": { "action": "submitForm" } }
}
},
"state": {
"form": {
"email": "",
"password": "",
"confirmPassword": "",
"accountType": "personal",
"company": ""
}
}
}Dynamic Validation Args
Validation arguments can reference other state values using { $state: "/path" } instead of literal values. This enables cross-field validation where the threshold itself is dynamic.
For example, a min validator whose minimum comes from another field:
{
"validator": "min",
"message": "Must be at least the minimum",
"args": { "min": { "$state": "/settings/minValue" } }
}At validation time, { "$state": "/settings/minValue" } resolves to whatever number is currently stored at that state path. If /settings/minValue is 10, the check behaves exactly like "args": { "min": 10 }.
Any argument value in args can be a DynamicValue — either a literal or a { $state } reference. This works with all validators that accept arguments (min, max, minLength, maxLength, pattern, matches, equalTo, lessThan, greaterThan, requiredIf).
Example: Cross-Field Password Matching with Dynamic Args
A registration form where the "confirm password" field dynamically references the password field:
{
"confirmField": {
"type": "Input",
"props": {
"label": "Confirm Password",
"type": "password",
"value": { "$bindState": "/form/confirmPassword" },
"validation": {
"checks": [
{ "validator": "required", "message": "Please confirm your password" },
{
"validator": "matches",
"message": "Passwords do not match",
"args": { "path": { "$state": "/form/password" } }
}
]
}
}
}
}The path argument resolves dynamically, so the validator always compares against the current password value.
Validation Helpers
When building specs in TypeScript, the check helper provides a cleaner API with sensible default messages. Each method returns a properly shaped ValidationCheck object:
import { check } from "@prototyperai/stream-ui/core"
const emailChecks = [
check.required(), // "This field is required"
check.email(), // "Invalid email address"
]
const passwordChecks = [
check.required(),
check.minLength(8), // "Must be at least 8 characters"
check.pattern("\\d", "Must contain a number"),
]
const confirmChecks = [
check.required(),
check.matches("/form/password", "Passwords do not match"),
]
// Dynamic args are supported too:
const rangeChecks = [
check.min({ $state: "/settings/minValue" }, "Below minimum"),
check.max({ $state: "/settings/maxValue" }, "Above maximum"),
]Available helpers: check.required(), check.email(), check.minLength(n), check.maxLength(n), check.pattern(regex), check.min(n), check.max(n), check.numeric(), check.url(), check.matches(path), check.equalTo(value), check.lessThan(path), check.greaterThan(path), check.requiredIf(path).
See the API Reference for complete signatures.
Programmatic Validation
For custom validation logic outside of the hook, use runValidation() directly:
import { runValidation, builtInValidators } from "@prototyperai/stream-ui/core"
const checks = [
{ validator: "required", message: "Required" },
{ validator: "email", message: "Invalid email" },
]
const result = runValidation(checks, "test@example.com")
// { valid: true, errors: [] }
const result2 = runValidation(checks, "not-an-email")
// { valid: false, errors: ["Invalid email"] }For cross-field validation, pass a ValidationContext:
const ctx = {
stateModel: { password: "secret123", confirmPassword: "secret456" },
getFieldValue: (path: string) => getByPath(ctx.stateModel, path),
}
const checks = [
{ validator: "matches", message: "Passwords must match", args: { path: "/password" } },
]
const result = runValidation(checks, "secret456", undefined, ctx)
// { valid: false, errors: ["Passwords must match"] }