schema-dsl best practices
Use this after you have working schemas and want production-ready structure, performance, and maintainability patterns. For project layout, continue with Project Structure Best Practices.
Schema design principles
1. Use pure DSL for simple fields
recommend:
const schema = s({
username: 'string:3-32!',
age: 'number:18-120',
email: 'email!',
role: 'admin|user|guest'
});
Not recommended (overly complex):
const schema = s({
username: s('string').min(3).max(32).require(),
// Too verbose!
});
Principle: Keep simple fields as compact DSL literals. Add chain methods only
when you need labels, messages, custom validators, or other refinements.
2. Chain calls for complex validation
Scenarios suitable for chain calls:
- Regular validation is required
- Need custom error message
- Requires custom validator
- label required
Example:
const schema = s({
// Simple fields: pure DSL
age: 'number:18-120',
// Complex fields: chain calls
username: s('string:3-32!')
.pattern(/^[a-zA-Z0-9_]+$/)
.label('username')
.messages({
'pattern': 'can only contain letters, numbers and underscores',
'min': 'At least 3 characters',
'max': 'Maximum 32 characters'
}),
email: s('email!')
.custom((value) => {
if (value.endsWith('@blocked.example')) return 'This email domain name is not allowed to be registered';
})
.label('email address')
});
3. Use a default validator
schema-dsl provides commonly used preset validators, available out of the box:
const schema = s({
// ✅ Use the default validator (recommended)
username: s('string!').username(), // Automatically set 3-32 length + regular
password: s('string!').password('strong'), // Strong password validation
phone: s('string!').phone('cn'), // China mobile phone number
// ❌ Manual implementation (not recommended)
username: s('string:3-32!')
.pattern(/^[a-zA-Z][a-zA-Z0-9_]*$/)
});
Available Presets:
username(preset?) - Username validation
password(strength?) - Password strength validation
phone(country?) - Mobile phone number validation
slug() - URL slug validation
4. Avoid too deep nesting
Not recommended (nested too deeply):
const schema = s({
user: {
profile: {
personal: {
address: {
detail: {
street: 'string' // Nested 5 levels
}
}
}
}
}
});
Recommended (Split or Flatten):
// Option 1: Split into multiple Schema
const addressSchema = s({
street: 'string!',
city: 'string!',
zipCode: 'string'
});
const userSchema = s({
name: 'string!',
email: 'email!',
address: addressSchema
});
// Option 2: Flatten
const schema = s({
'user_name': 'string!',
'user_email': 'email!',
'address_street': 'string!',
'address_city': 'string!'
});
Principle: It is recommended that the nesting depth should not exceed 3-4 levels.
1. Precompile Schema
Not recommended (compile every time):
app.post('/api/user', (req, res) => {
const schema = s({ username: 'string!' });
const result = validate(schema, req.body); // Compile every time
});
Recommended (pre-compiled):
// Compile once when the application starts
const validator = new Validator();
const userSchema = s({ username: 'string!' });
const validateUser = validator.compile(userSchema);
app.post('/api/user', (req, res) => {
const result = validateUser(req.body); // Use directly
});
Benefits: Reusing compiled results can significantly reduce repeated compilation costs, especially suitable for hotspot routing and high-frequency validation paths.
2. Enable caching
const validator = new Validator({
cache: true // ✅ Abbreviation: enable default compilation cache configuration
});
// When finer granularity is required, use object configuration
const tunedValidator = new Validator({
cache: {
enabled: true,
maxSize: 500,
ttl: 60 * 60 * 1000
}
});
// Or use a global singleton (caching is enabled by default)
import { validate } from 'schema-dsl/pure';
validate(schema, data); // automatic caching
3. Batch validation
Not recommended (loop validation):
const errors = [];
records.forEach(record => {
const result = validate(schema, record);
if (!result.valid) {
errors.push(result.errors);
}
});
Recommended (batch validation):
import { SchemaUtils, Validator } from 'schema-dsl/pure';
const validator = new Validator();
const result = SchemaUtils.validateBatch(schema, records, validator.getAjv());
// When you have reused the underlying Ajv instance of Validator, this path is suitable for batch validation
ℹ️ If you really want to directly pass in the Ajv instance you created, please first ensure that it has registered the format and keywords that match the schema generated by schema-dsl; for most projects, it is safer to reuse validator.getAjv() directly.
4. Optimize regular expressions
Not recommended (may cause ReDoS):
// Dangerous regex: catastrophic backtracking
.pattern(/^(a+)+$/)
.pattern(/^(a*)*$/)
.pattern(/^(a|a)*$/)
Recommended (safe and efficient):
// Simple and clear regular rules
.pattern(/^[a-zA-Z0-9_]+$/)
.pattern(/^[a-z]{3,10}$/)
Tool: Use safe-regex2 to detect dangerous regexes.
5. Avoid creating Schemas in loops
Not recommended:
records.forEach(record => {
const schema = s({ name: 'string!' }); // Create every time
validate(schema, record);
});
recommend:
const schema = s({ name: 'string!' }); // Create once
records.forEach(record => {
validate(schema, record); //reuse
});
security considerations
Danger:
// ❌ User-controlled regular expression
app.post('/api/validate', (req, res) => {
const pattern = req.body.pattern; // User input
const schema = s('string').pattern(new RegExp(pattern)); // Danger!
});
Cause: Users may enter malicious regular expressions to cause ReDoS attacks.
SAFE PRACTICE:
// ✅ Use predefined regular expressions
const ALLOWED_PATTERNS = {
username: /^[a-zA-Z0-9_]+$/,
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
};
app.post('/api/validate', (req, res) => {
const patternName = req.body.pattern;
const pattern = ALLOWED_PATTERNS[patternName];
if (!pattern) {
return res.status(400).json({ error: 'Invalid pattern' });
}
const schema = s('string').pattern(pattern);
});
2. Clean up error messages
Do not expose sensitive information in the production environment:
// development environment
if (process.env.NODE_ENV === 'development') {
return res.status(400).json({
valid: false,
errors: result.errors // Detailed errors
});
}
// production environment
return res.status(400).json({
valid: false,
message: 'Input data validation failed' // Simplified message
});
3. Limit Schema complexity
const MAX_SCHEMA_SIZE = 10000;
if (JSON.stringify(schema).length > MAX_SCHEMA_SIZE) {
throw new Error('Schema is too large, it is recommended to split');
}
// Check before validate
const depthCheck = DslBuilder.validateNestingDepth(schema, 10);
if (!depthCheck.valid) {
throw new Error(depthCheck.message);
}
4. Prevent prototype contamination
// Avoid prototype pollution when validating data
const validator = new Validator({
removeAdditional: true, // Remove additional attributes
useDefaults: false // Do not automatically fill in default values (if not needed)
});
Error handling
Recommended error handling middleware:
// Express middleware
function validateMiddleware(schema) {
return (req, res, next) => {
const result = validate(schema, req.body);
if (!result.valid) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'Request data validation failed',
errors: result.errors.map(err => ({
field: err.path,
message: err.message
}))
});
}
next();
};
}
// use
app.post('/api/user',
validateMiddleware(userSchema),
userController.create
);
2. Friendly error messages
Use labels and custom messages:
const schema = s({
username: s('string:3-32!').label('username')
.messages({
'required': '{{#label}} cannot be empty',
'min': '{{#label}} requires at least {{#limit}} characters',
'max': '{{#label}} is at most {{#limit}} characters',
'pattern': '{{#label}} format is incorrect'
}),
email: s('email!')
.label('email address')
.messages({
'required': 'Please fill in {{#label}}',
'format': '{{#label}} format is incorrect'
})
});
Effect:
❌ Username cannot be empty
❌ Username must be at least 3 characters
✅ Clear and user-friendly
3. Handle external asynchronous validation errors
.custom() supports synchronous functions; when asynchronous checks such as database, RPC, HTTP, etc. are involved, Promise can be returned and executed through validateAsync(), or it can be executed separately in the business layer after the basic validation is passed.
const schema = s({
email: s('email!').label('email address')
});
async function validateUser(data) {
const result = validate(schema, data);
if (!result.valid) return result;
try {
const exists = await checkEmailExists(data.email);
if (exists) {
return {
valid: false,
errors: [{ field: 'email', keyword: 'business', message: 'Email has been occupied' }]
};
}
} catch (error) {
console.error('Email check failed:', error);
}
return result;
}
code organization
1. Centrally manage Schema
Recommended Project Structure:
src/
├── schemas/
│ ├── index.js # Export all Schemas
│ ├── user.schema.js # User related Schema
│ ├── post.schema.js # Article related Schema
│ └── common.schema.js # Common Schema
├── routes/
│ ├── user.routes.js
│ └── post.routes.js
└── controllers/
schemas/user.schema.js:
import { s } from 'schema-dsl/pure';
//Reusable fields
const commonFields = {
username: s('string!').username().label('username'),
email: 'email!',
password: s('string!').password('strong').label('password')
};
// Register Schema
export const registerSchema = s({
...commonFields,
confirmPassword: 'string!',
agreeTerms: 'boolean!'
});
// Login Schema
export const loginSchema = s({
email: commonFields.email,
password: commonFields.password
});
// Update Schema
export const updateSchema = s({
username: commonFields.username,
email: commonFields.email
// Does not contain password
});
schemas/index.js:
import * as userSchemas from './user.schema.js';
import * as postSchemas from './post.schema.js';
export default {
user: userSchemas,
post: postSchemas
};
routes/user.routes.js:
import schemas from '../schemas';
import { validate } from 'schema-dsl/pure';
router.post('/register', (req, res) => {
const result = validate(schemas.user.registerSchema, req.body);
// ...
});
2. Schema reuse
Using SchemaUtils:
import { SchemaUtils, s } from 'schema-dsl/pure';
//Create a reusable field library
const fields = SchemaUtils.createLibrary({
email: () => 'email!',
phone: () => s('string!').phone('cn'),
password: () => s('string!').password('strong')
});
//Reuse in multiple Schema
const registerSchema = s({
email: fields.email(),
password: fields.password(),
username: 'string:3-32!'
});
const profileSchema = s({
email: fields.email(),
phone: fields.phone(),
bio: 'string:500'
});
Production environment recommendations
1. Environment configuration
// config/validator.js
import { Validator } from 'schema-dsl/pure';
const config = {
development: {
verbose: true,
allErrors: true,
cache: false // ✅ Abbreviation: turn off cache for easy debugging
},
production: {
verbose: false,
allErrors: false, //Only return the first error
cache: {
enabled: true,
maxSize: 1000,
ttl: 60 * 60 * 1000
}
}
};
export const validator = new Validator(
config[process.env.NODE_ENV || 'development']
);
2. Monitoring and logging
const validator = new Validator();
//Wrap the validate method and add monitoring
const originalValidate = validator.validate.bind(validator);
validator.validate = function(schema, data, options) {
const startTime = Date.now();
const result = originalValidate(schema, data, options);
const duration = Date.now() - startTime;
//Record slow queries
if (duration > 100) {
console.warn(`Slow validation: ${duration}ms`);
}
//Record validation failure
if (!result.valid) {
logger.info('Validation failed', {
errors: result.errors.length,
paths: result.errors.map(e => e.path)
});
}
return result;
};
3. Health Checkup
// routes/health.js
import { validator } from '../config/validator.js';
app.get('/health', (req, res) => {
// Check if the validator is normal
try {
const testSchema = s({ test: 'string!' });
const result = validator.validate(testSchema, { test: 'ok' });
if (!result.valid) {
throw new Error('Validator test failed');
}
res.json({
status: 'ok',
validator: 'operational',
cacheStats: validator.getCacheStats()
});
} catch (error) {
res.status(500).json({
status: 'error',
message: error.message
});
}
});
4. Regular maintenance
// Clean the cache regularly
import cron from 'node-cron';
// Clean up once every morning
cron.schedule('0 0 * * *', () => {
validator.clearCache();
console.log('Validator cache cleared');
});
// Or clean up based on memory usage
setInterval(() => {
const memUsage = process.memoryUsage();
if (memUsage.heapUsed > 500 * 1024 * 1024) { // More than 500MB
validator.clearCache();
}
}, 60000); // Check every minute
Cache hits before and after can usually significantly reduce the cost of repeated compilation, but the absolute time-consuming will be affected by machine performance, Node version, schema complexity, data size and hit rate. It is not recommended to use a fixed set of milliseconds as a universal benchmark.
More stable conclusion:
- Reusing the same schema object or
Validator instance is usually faster than recompiling for each request
- The more complex the schema and the more times it is re-validated, the more obvious the cache benefits are.
- The total time required for batch validation mainly depends on the complexity of a single schema and the size of the data. A fixed number of milliseconds should not be used to make capacity commitments.
If you need current reviewable throughput comparison, please refer to the benchmark results under maintenance and the performance data synchronized in the FAQ.
Summarize
Following these best practices, your schema-dsl code will have:
✅ HIGH PERFORMANCE - via pre-compilation and caching
✅ HIGH SECURITY - Avoid common security pitfalls
✅ High Maintainability - Clear code organization
✅ High Availability - Perfect error handling
Further reading
Corresponding sample file
Example entry: best-practices.ts
Description: Shows the recommended combination of "using pure DSL for simple fields, partially using Builder for complex fields, and reusing field libraries", as well as the two validation paths of success/failure.