Creating Custom Validation Adapters
If none of the built-in validation adapters (Zod, Yup, Valibot, EncolaJS Validator) meet your needs, you can create your own custom validation adapter.
FormValidator Interface
All validation adapters must implement the FormValidator interface:
typescript
interface FormValidator {
/** Validate a specific field */
validateField(path: string, dataSource: DataSource): Promise<string[]>
/** Validate entire form */
validate(dataSource: DataSource): Promise<Record<string, string[]>>
/** Get current field errors */
getFieldErrors(path: string): string[]
/** Get all current errors */
getAllErrors(): Record<string, string[]>
/** Check if field is valid */
isFieldValid(path: string): boolean
/** Check if entire form is valid */
isValid(): boolean
/** Get fields that depend on the given field */
getDependentFields(path: string): string[]
/** Clear errors for specific field */
clearFieldErrors(path: string): void
/** Clear all errors */
clearAllErrors(): void
/** Set errors for specific field */
setFieldErrors(path: string, errors: string[]): void
/** Set multiple field errors */
setErrors(errors: Record<string, string[]>): void
}Basic Custom Validator
Here's a simple example of a custom validator that validates our standard user schema:
javascript
import createForm, { FormController, PlainObjectDataSource } from '@encolajs/form-controller'
class CustomValidator {
constructor() {
this.errors = {}
}
async validateField(path, dataSource) {
const value = dataSource.get(path)
const errors = []
// Apply validation rules based on field path
switch (path) {
case 'name':
if (!value || value.length < 2) {
errors.push('Name must be at least 2 characters')
}
if (value && value.length > 50) {
errors.push('Name must be less than 50 characters')
}
break
case 'email':
if (!value) {
errors.push('Email is required')
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors.push('Please enter a valid email address')
}
break
case 'age':
if (!value) {
errors.push('Age is required')
} else if (value < 18) {
errors.push('Must be at least 18 years old')
} else if (value > 120) {
errors.push('Please enter a valid age')
}
break
case 'password':
if (!value) {
errors.push('Password is required')
} else {
if (value.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (!/[A-Z]/.test(value)) {
errors.push('Password must contain at least one uppercase letter')
}
if (!/[a-z]/.test(value)) {
errors.push('Password must contain at least one lowercase letter')
}
if (!/[0-9]/.test(value)) {
errors.push('Password must contain at least one number')
}
}
break
case 'confirmPassword':
const password = dataSource.get('password')
if (!value) {
errors.push('Please confirm your password')
} else if (value !== password) {
errors.push("Passwords don't match")
}
break
case 'profile.bio':
if (value && value.length > 500) {
errors.push('Bio must be less than 500 characters')
}
break
case 'profile.website':
if (value && value !== '' && !/^https?:\/\/.+/.test(value)) {
errors.push('Please enter a valid URL')
}
break
default:
// Handle array fields for contacts
if (path.startsWith('contacts.') && path.endsWith('.name')) {
if (!value || value.length === 0) {
errors.push('Contact name is required')
}
} else if (path.startsWith('contacts.') && path.endsWith('.email')) {
if (!value) {
errors.push('Contact email is required')
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors.push('Please enter a valid email address')
}
}
break
}
// Update internal errors state
if (errors.length > 0) {
this.errors[path] = errors
} else {
delete this.errors[path]
}
return errors
}
async validate(dataSource) {
const allData = dataSource.all()
const allErrors = {}
// Validate all fields
const fieldPaths = this.getAllFieldPaths(allData)
for (const path of fieldPaths) {
const errors = await this.validateField(path, dataSource)
if (errors.length > 0) {
allErrors[path] = errors
}
}
this.errors = allErrors
return allErrors
}
getFieldErrors(path) {
return this.errors[path] || []
}
getAllErrors() {
return { ...this.errors }
}
isFieldValid(path) {
return !this.errors[path] || this.errors[path].length === 0
}
isValid() {
return Object.keys(this.errors).length === 0
}
getDependentFields(path) {
// Return fields that should be re-validated when this field changes
switch (path) {
case 'password':
return ['confirmPassword']
default:
return []
}
}
clearFieldErrors(path) {
delete this.errors[path]
}
clearAllErrors() {
this.errors = {}
}
setFieldErrors(path, errors) {
if (errors.length > 0) {
this.errors[path] = errors
} else {
delete this.errors[path]
}
}
setErrors(errors) {
this.errors = { ...errors }
}
// Helper method to get all field paths from data
getAllFieldPaths(data, prefix = '') {
const paths = []
for (const key in data) {
const currentPath = prefix ? `${prefix}.${key}` : key
const value = data[key]
paths.push(currentPath)
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
paths.push(...this.getAllFieldPaths(item, `${currentPath}.${index}`))
} else {
paths.push(`${currentPath}.${index}`)
}
})
} else if (typeof value === 'object' && value !== null) {
paths.push(...this.getAllFieldPaths(value, currentPath))
}
}
return paths
}
}
// Usage
const validator = new CustomValidator()
const dataSource = new PlainObjectDataSource({
name: '',
email: '',
age: 18,
password: '',
confirmPassword: '',
profile: {
bio: '',
website: ''
},
preferences: {
newsletter: false,
notifications: true
},
contacts: []
})
const form = createForm(dataSource, validator)Advanced Custom Validator
For more complex scenarios, you can create a rule-based validator:
javascript
class RuleBasedValidator {
constructor(rules = {}) {
this.rules = rules
this.errors = {}
}
addRule(field, rule) {
if (!this.rules[field]) {
this.rules[field] = []
}
this.rules[field].push(rule)
}
async validateField(path, dataSource) {
const value = dataSource.get(path)
const fieldRules = this.rules[path] || []
const errors = []
for (const rule of fieldRules) {
const result = await this.executeRule(rule, value, path, dataSource)
if (result !== true) {
errors.push(result)
}
}
if (errors.length > 0) {
this.errors[path] = errors
} else {
delete this.errors[path]
}
return errors
}
async executeRule(rule, value, path, dataSource) {
switch (rule.type) {
case 'required':
return value && value.toString().trim().length > 0
? true
: rule.message || 'This field is required'
case 'minLength':
return !value || value.length >= rule.min
? true
: rule.message || `Must be at least ${rule.min} characters`
case 'maxLength':
return !value || value.length <= rule.max
? true
: rule.message || `Must be no more than ${rule.max} characters`
case 'email':
return !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
? true
: rule.message || 'Please enter a valid email address'
case 'min':
return !value || value >= rule.min
? true
: rule.message || `Must be at least ${rule.min}`
case 'max':
return !value || value <= rule.max
? true
: rule.message || `Must be no more than ${rule.max}`
case 'regex':
return !value || rule.pattern.test(value)
? true
: rule.message || 'Invalid format'
case 'same':
const compareValue = dataSource.get(rule.field)
return value === compareValue
? true
: rule.message || `Must match ${rule.field}`
case 'custom':
return await rule.validator(value, path, dataSource)
default:
return true
}
}
// Implement other required methods...
async validate(dataSource) {
const allErrors = {}
for (const field in this.rules) {
const errors = await this.validateField(field, dataSource)
if (errors.length > 0) {
allErrors[field] = errors
}
}
this.errors = allErrors
return allErrors
}
getFieldErrors(path) { return this.errors[path] || [] }
getAllErrors() { return { ...this.errors } }
isFieldValid(path) { return !this.errors[path] || this.errors[path].length === 0 }
isValid() { return Object.keys(this.errors).length === 0 }
getDependentFields(path) { return [] }
clearFieldErrors(path) { delete this.errors[path] }
clearAllErrors() { this.errors = {} }
setFieldErrors(path, errors) {
if (errors.length > 0) {
this.errors[path] = errors
} else {
delete this.errors[path]
}
}
setErrors(errors) { this.errors = { ...errors } }
}
// Usage with rules
const validator = new RuleBasedValidator()
// Add validation rules
validator.addRule('name', { type: 'required', message: 'Name is required' })
validator.addRule('name', { type: 'minLength', min: 2, message: 'Name must be at least 2 characters' })
validator.addRule('name', { type: 'maxLength', max: 50, message: 'Name must be less than 50 characters' })
validator.addRule('email', { type: 'required', message: 'Email is required' })
validator.addRule('email', { type: 'email', message: 'Please enter a valid email address' })
validator.addRule('age', { type: 'required', message: 'Age is required' })
validator.addRule('age', { type: 'min', min: 18, message: 'Must be at least 18 years old' })
validator.addRule('age', { type: 'max', max: 120, message: 'Please enter a valid age' })
validator.addRule('password', { type: 'required', message: 'Password is required' })
validator.addRule('password', { type: 'minLength', min: 8, message: 'Password must be at least 8 characters' })
validator.addRule('password', {
type: 'regex',
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Password must contain uppercase, lowercase, and number'
})
validator.addRule('confirmPassword', { type: 'required', message: 'Please confirm your password' })
validator.addRule('confirmPassword', { type: 'same', field: 'password', message: "Passwords don't match" })
// Add custom validation
validator.addRule('contacts.*.name', {
type: 'custom',
validator: async (value, path, dataSource) => {
if (!value || value.length === 0) {
return 'Contact name is required'
}
return true
}
})
validator.addRule('contacts.*.email', {
type: 'custom',
validator: async (value, path, dataSource) => {
if (!value) {
return 'Contact email is required'
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address'
}
return true
}
})Integrating 3rd-party Libraries
You can also create adapters for external validation libraries that aren't natively supported:
javascript
// Example: Joi adapter
class JoiValidatorAdapter {
constructor(schema) {
this.schema = schema
this.errors = {}
}
async validateField(path, dataSource) {
try {
const allData = dataSource.all()
await this.schema.validateAsync(allData, { allowUnknown: true })
// If validation passes, clear errors for this field
delete this.errors[path]
return []
} catch (error) {
// Extract errors for the specific field
const fieldErrors = error.details
.filter(detail => detail.path.join('.') === path)
.map(detail => detail.message)
if (fieldErrors.length > 0) {
this.errors[path] = fieldErrors
}
return fieldErrors
}
}
async validate(dataSource) {
try {
const allData = dataSource.all()
await this.schema.validateAsync(allData)
this.errors = {}
return {}
} catch (error) {
const errors = {}
error.details.forEach(detail => {
const path = detail.path.join('.')
if (!errors[path]) {
errors[path] = []
}
errors[path].push(detail.message)
})
this.errors = errors
return errors
}
}
// Implement other required methods...
getFieldErrors(path) { return this.errors[path] || [] }
getAllErrors() { return { ...this.errors } }
isFieldValid(path) { return !this.errors[path] || this.errors[path].length === 0 }
isValid() { return Object.keys(this.errors).length === 0 }
getDependentFields(path) { return [] }
clearFieldErrors(path) { delete this.errors[path] }
clearAllErrors() { this.errors = {} }
setFieldErrors(path, errors) {
if (errors.length > 0) {
this.errors[path] = errors
} else {
delete this.errors[path]
}
}
setErrors(errors) { this.errors = { ...errors } }
}Testing Your Custom Validator
javascript
// Test your custom validator
const validator = new CustomValidator()
const dataSource = new PlainObjectDataSource({
name: 'Jo', // Too short
email: 'invalid-email', // Invalid format
age: 16, // Too young
password: 'weak', // Too weak
confirmPassword: 'different', // Doesn't match
contacts: [
{ name: '', email: 'invalid' } // Both invalid
]
})
// Test individual field validation
const nameErrors = await validator.validateField('name', dataSource)
console.log('Name errors:', nameErrors)
// Test full form validation
const allErrors = await validator.validate(dataSource)
console.log('All errors:', allErrors)
// Test error state methods
console.log('Is valid:', validator.isValid())
console.log('Is name valid:', validator.isFieldValid('name'))Creating custom validators gives you complete control over validation logic while maintaining compatibility with the EncolaJS Form Controller ecosystem.
