Custom Validation Example
Add your own validation logic beyond JSON Schema constraints.
Basic Custom Validator
vue
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicForm } from '@quickflo/quickforms-vue'
import type { JSONSchema } from '@quickflo/quickforms'
const schema: JSONSchema = {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
minLength: 3
},
password: {
type: 'string',
format: 'password',
title: 'Password',
minLength: 8
},
confirmPassword: {
type: 'string',
format: 'password',
title: 'Confirm Password'
}
},
required: ['username', 'password', 'confirmPassword']
}
const formData = ref({})
const options = {
validators: {
// Sync validator
username: (value, allValues) => {
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
return 'Username can only contain letters, numbers, and underscores'
}
if (value.toLowerCase() === 'admin') {
return 'Username "admin" is reserved'
}
return true
},
// Password strength validator
password: (value) => {
if (!/[A-Z]/.test(value)) {
return 'Password must contain at least one uppercase letter'
}
if (!/[a-z]/.test(value)) {
return 'Password must contain at least one lowercase letter'
}
if (!/[0-9]/.test(value)) {
return 'Password must contain at least one number'
}
return true
},
// Cross-field validation
confirmPassword: (value, allValues) => {
if (value !== allValues.password) {
return 'Passwords must match'
}
return true
}
}
}
</script>
<template>
<DynamicForm
:schema="schema"
v-model="formData"
:options="options"
/>
</template>Async Validation
vue
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicForm } from '@quickflo/quickforms-vue'
const schema = {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
title: 'Email Address'
}
}
}
const formData = ref({})
const options = {
validators: {
// Async validator - checks if email is available
email: async (value) => {
if (!value) return true
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500))
const response = await fetch(`/api/check-email?email=${value}`)
const { available } = await response.json()
return available || 'This email is already registered'
}
},
// Debounce async validation
validatorDebounce: {
email: 500 // Wait 500ms after typing stops
}
}
</script>
<template>
<DynamicForm :schema="schema" v-model="formData" :options="options" />
</template>Complex Business Logic
typescript
const options = {
validators: {
// Age validation with context
age: (value, allValues, context) => {
const minAge = context.minimumAge || 18
if (value < minAge) {
return `Must be at least ${minAge} years old`
}
return true
},
// Credit card validation
cardNumber: (value) => {
// Luhn algorithm
const digits = value.replace(/\s/g, '').split('').reverse()
let sum = 0
for (let i = 0; i < digits.length; i++) {
let digit = parseInt(digits[i])
if (i % 2 === 1) {
digit *= 2
if (digit > 9) digit -= 9
}
sum += digit
}
return sum % 10 === 0 || 'Invalid credit card number'
},
// Date range validation
endDate: (value, allValues) => {
if (!value || !allValues.startDate) return true
const start = new Date(allValues.startDate)
const end = new Date(value)
if (end <= start) {
return 'End date must be after start date'
}
return true
},
// File upload validation
file: (value) => {
if (!value) return true
const maxSize = 5 * 1024 * 1024 // 5MB
if (value.size > maxSize) {
return 'File size must be less than 5MB'
}
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (!allowedTypes.includes(value.type)) {
return 'File must be JPEG, PNG, or PDF'
}
return true
}
},
// Provide context
context: {
minimumAge: 21
}
}Validation Return Types
Validators can return different types:
typescript
const validators = {
field1: (value) => {
// Boolean
return true // Valid
return false // Invalid (generic error)
},
field2: (value) => {
// String error message
return 'Custom error message'
return true // Valid
},
field3: async (value) => {
// Promise<boolean | string>
const result = await validateAsync(value)
return result.isValid || result.errorMessage
},
field4: (value) => {
// Validation result object
return {
valid: false,
message: 'Custom error message'
}
}
}Error Messages Priority
QuickForms checks error messages in this order:
- Custom validator errors (highest priority)
x-error-messagesin schemaerrorMessagesin options- Default error messages (lowest priority)
vue
<script setup lang="ts">
const schema = {
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
// Schema-level custom messages
'x-error-messages': {
required: 'Email is required',
format: 'Please enter a valid email address'
}
}
}
}
const options = {
// Form-level custom messages
errorMessages: {
email: {
required: 'Email cannot be empty',
format: 'Invalid email format'
}
},
// Custom validator (takes precedence over all)
validators: {
email: async (value) => {
const exists = await checkEmail(value)
return exists || 'This email is already taken' // Highest priority
}
}
}
</script>Validation Modes
Control when/how validation errors are displayed:
typescript
const options = {
// ValidateAndShow - Show errors as you type (default)
validationMode: 'ValidateAndShow',
// ValidateAndHide - Validate silently, don't show errors
// validationMode: 'ValidateAndHide',
// NoValidation - Skip all validation
// validationMode: 'NoValidation',
validators: {
email: async (value) => {
// Validation still runs in all modes
// But errors only shown in ValidateAndShow
return await checkEmail(value)
}
}
}Complete Example: Registration Form
vue
<script setup lang="ts">
import { ref } from 'vue'
import { DynamicForm } from '@quickflo/quickforms-vue'
const schema = {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
minLength: 3,
maxLength: 20,
'x-hint': 'Letters, numbers, and underscores only'
},
email: {
type: 'string',
format: 'email',
title: 'Email Address'
},
password: {
type: 'string',
format: 'password',
title: 'Password',
minLength: 8,
'x-hint': 'At least 8 characters with uppercase, lowercase, and numbers'
},
confirmPassword: {
type: 'string',
format: 'password',
title: 'Confirm Password'
},
age: {
type: 'number',
title: 'Age',
minimum: 13
},
termsAccepted: {
type: 'boolean',
title: 'I accept the terms and conditions'
}
},
required: ['username', 'email', 'password', 'confirmPassword', 'age', 'termsAccepted']
}
const formData = ref({})
const options = {
validators: {
username: async (value) => {
// Format validation
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
return 'Only letters, numbers, and underscores allowed'
}
// Reserved words
const reserved = ['admin', 'root', 'system']
if (reserved.includes(value.toLowerCase())) {
return 'This username is reserved'
}
// Check availability (async)
const response = await fetch(`/api/check-username?username=${value}`)
const { available } = await response.json()
return available || 'Username already taken'
},
email: async (value) => {
// Check if email exists
const response = await fetch(`/api/check-email?email=${value}`)
const { available } = await response.json()
return available || 'Email already registered'
},
password: (value) => {
if (!/[A-Z]/.test(value)) return 'Must contain uppercase letter'
if (!/[a-z]/.test(value)) return 'Must contain lowercase letter'
if (!/[0-9]/.test(value)) return 'Must contain number'
if (/\s/.test(value)) return 'Cannot contain spaces'
return true
},
confirmPassword: (value, allValues) => {
return value === allValues.password || 'Passwords do not match'
},
age: (value, allValues, context) => {
if (value < 13) return 'Must be at least 13 years old'
if (value > 120) return 'Please enter a valid age'
return true
},
termsAccepted: (value) => {
return value === true || 'You must accept the terms to continue'
}
},
// Debounce async validators
validatorDebounce: {
username: 500,
email: 500
},
// Custom error messages as fallback
errorMessages: {
username: {
required: 'Username is required',
minLength: 'Username must be at least 3 characters'
},
password: {
minLength: 'Password must be at least 8 characters'
}
}
}
const handleValidation = (result) => {
console.log('Form valid:', result.valid)
console.log('Errors:', result.errors)
}
</script>
<template>
<DynamicForm
:schema="schema"
v-model="formData"
:options="options"
@validation="handleValidation"
/>
</template>Tips
- Return boolean
truefor valid - Don't return a success message - Be specific with errors - Clear error messages improve UX
- Debounce async validators - Prevent excessive API calls
- Access all values - Use second parameter for cross-field validation
- Use context - Pass dynamic configuration via context object
- Handle async errors - Wrap async code in try/catch
Next Steps
- Custom Validators Guide - Deep dive into validators
- Validation Guide - JSON Schema validation
- Form Options - All form options