Validation and Schemas
ExisJS includes a built-in schema validation and sanitization engine named tex. It provides compile-time TypeScript type inference and runtime payload validation without requiring external schema dependencies.
The tex Validator
Import tex from exisjs/validator to declare schemas for request bodies, query strings, path parameters, and headers.
import { tex } from 'exisjs/validator'import type { ResolveSchema } from 'exisjs/validator'
export const CreateUserSchema = tex.object({ name: tex.string({ min: 3, max: 50, trim: true }), email: tex.email({ toLowerCase: true }), age: tex.number({ optional: true, min: 18 }), role: tex.enum(['admin', 'member'], { default: 'member' }),})
export type CreateUserInput = ResolveSchema<typeof CreateUserSchema>You can validate data manually using .parse():
const validated = CreateUserSchema.parse(rawInput)If validation fails, .parse() throws a ValidatorError containing field-level error descriptors. When used inside routes, ExisJS catches validation failures automatically and returns an HTTP 400 Bad Request response.
Schema Primitives Reference
ExisJS provides type-safe primitives that accept modifier options and configuration objects.
| Primitive | Signature | Description |
|---|---|---|
tex.string() | tex.string(options?) | Validates strings. Supports { min, max, length, pattern, trim, toLowerCase, toUpperCase, collapseWhitespace, escapeHtml, stripHtml, slugify, mask, preventSql, preventTraversal, coerce, default }. pattern is a RegExp checked after trimming and case changes. |
tex.number() | tex.number(options?) | Validates finite numbers. Supports { int, min, max, coerce, default }; int: true accepts whole numbers only. |
tex.boolean() | tex.boolean(options?) | Validates booleans. Supports { coerce, default }. Coerces "true", "false", "1", "0" (any case, surrounding spaces ignored) and 1 / 0. |
tex.email() | tex.email(options?) | Validates RFC-compliant email formatting. Supports { trim, toLowerCase, mask, default }. |
tex.url() | tex.url(options?) | Validates absolute URLs. Only http and https unless { protocols: ['https'] } says otherwise, so javascript: links are rejected. Supports { trim, min, max, pattern, default }. |
tex.uuid() | tex.uuid(options?) | Validates UUID strings. Supports { version: 1 | 4, default }. |
tex.cuid() | tex.cuid(options?) | Validates CUID identifiers. Supports { default }. |
tex.password() | tex.password(options?) | Validates password complexity. Supports { min, max, requireNumbers, requireSymbols, requireUppercase, requireLowercase }. |
tex.creditCard() | tex.creditCard(options?) | Validates credit card numbers using the Luhn algorithm. Supports { mask: true }. |
tex.date() | tex.date(options?) | Validates a Date, a date string or a timestamp, with { coerce, minDate, maxDate, default }. An unparsable value is rejected, and the parsed value is always a Date (coerce is accepted but no longer needed). |
tex.array() | tex.array(schema, options?) | Validates arrays of items. Accepts primitive schemas, tex.object(...), or shorthand { ... }. Supports { min, max, dedupe, coerce, default }. |
tex.enum() | tex.enum(values, options?) | Validates against an allowed list of string literals. Supports { default }. |
tex.literal() | tex.literal(value, options?) | Validates against an exact primitive value (string | number | boolean). |
tex.union() | tex.union(schemas, options?) | Validates against any matching schema in the provided tuple. |
tex.discriminatedUnion() | tex.discriminatedUnion(tag, variants) | Validates polymorphic object variants discriminated by a literal or enum key. |
tex.record() | tex.record(valueSchema, options?) | Validates dynamic key-value dictionaries (Record<string, T>). |
tex.any() | tex.any(options?) | Pass-through validator without schema constraints. |
tex.file() | tex.file(options?) | Validates multipart file uploads. Supports { maxSize, mimeTypes }. |
tex.object() | tex.object(shape, options?) | Compiles an object schema. Supports { strict, optional, nullable, nullish }: strict: true rejects unlisted keys; the others work as for fields when the object is nested. An unknown option throws. |
tex.coerce.* | tex.coerce.number() | boolean() | date() | string() | Explicit coercion namespace for query strings, route parameters, and form inputs. |
tex.pagination() | tex.pagination(options?) | Predefined schema for page, limit, sort, and order query parameters. |
tex.env() | tex.env(shape, options?) | Environment variable schema parser with automatic type coercion from string values. |
Modifiers and Defaults
All primitive builders accept modifier options directly in their configuration argument, or through chaining helper methods:
| Modifier | Options Argument | Method Helper | Behavior |
|---|---|---|---|
| Optional | { optional: true } | .optional() | Allows undefined. |
| Nullable | { nullable: true } | .nullable() | Allows null. |
| Nullish | { nullish: true } | .nullish() | Allows both null and undefined. |
| Default | { default: value | () => value } | .default(value) | Used when the field is missing (or blank, for a form field). A client may omit the field, and after parsing it always has a value. Object and array defaults are copied, so parses never share them. |
import { tex } from 'exisjs/validator'
const Schema = tex.object({ nickname: tex.string({ optional: true }), avatarUrl: tex.string().nullable(), bio: tex.string().nullish(), retryCount: tex.number({ default: 3 }), createdAt: tex.date({ default: () => new Date() }),})Every method returns a new schema, so a field schema can be shared between objects: Name.optional() in one object does not make Name optional anywhere else. parse() never modifies the object it is given; it returns a new one.
Nested object schemas take the same modifiers as methods: .optional(), .nullable() and .nullish(). A plain object literal is also a nested object:
const Address = tex.object({ city: tex.string(), pin: tex.string({ optional: true }) })
export const UpdateLead = tex.object({ status: tex.enum(['new', 'won', 'lost']), address: Address.optional(), // may be left out billing: Address.nullish(), // may be left out or sent as null to clear it meta: { source: tex.string() }, // same as tex.object({ source: tex.string() })})Input and Output Types
A field with a default may be left out of a request but is always present after parsing, so the two sides have different types:
import { tex, type ResolveInput, type ResolveOutput } from 'exisjs/validator'
export const MODULES = ['ALL', 'EV', 'PV'] as const // readonly arrays work in tex.enum
export const ReportQuery = tex.object({ module: tex.enum(MODULES, { default: 'ALL' }), search: tex.string({ optional: true }),})
type Query = ResolveOutput<typeof ReportQuery> // what the handler gets: module is 'ALL' | 'EV' | 'PV'type QueryInput = ResolveInput<typeof ReportQuery> // what a client sends: module may be omittedResolveSchema is the same as ResolveOutput. Validation reports every failing field, not only the first.
[!NOTE]
Empty String Normalization: When query parameters or form submissions supply empty strings "" for fields marked .optional(), .nullable(), or .nullish(), ExisJS converts them to undefined or null before validation to avoid type conversion errors.
Route Validation
Pass schemas directly into route handler configurations or decorators. ExisJS validates incoming requests before the handler executes.
Functional Paradigm
import { controller, route } from 'exisjs/router'import { tex } from 'exisjs/validator'
const CreateUserBody = tex.object({ username: tex.string({ min: 4, max: 20 }), email: tex.email(), password: tex.password({ min: 8, requireNumbers: true }),})
const UserQuery = tex.object({ source: tex.string({ optional: true }),})
export default controller({ create: route.post('/', { body: CreateUserBody, query: UserQuery, async handle({ body, query, res }) { // body: { username: string; email: string; password: string } // query: { source?: string } res.status(201).json({ created: body.username, source: query.source }) }, }),})Class-Based (OOP) Paradigm
import { Controller, Post, Body, Query, HttpCode } from 'exisjs/decorators'import { tex } from 'exisjs/validator'import type { ResolveSchema } from 'exisjs/validator'
const CreateUserBody = tex.object({ username: tex.string({ min: 4, max: 20 }), email: tex.email(), password: tex.password({ min: 8, requireNumbers: true }),})
const UserQuery = tex.object({ source: tex.string({ optional: true }),})
type CreateUserDto = ResolveSchema<typeof CreateUserBody>type UserQueryDto = ResolveSchema<typeof UserQuery>
@Controller('/users')export default class UsersController { @Post('/') @HttpCode(201) async create( @Body(CreateUserBody) body: CreateUserDto, @Query(UserQuery) query: UserQueryDto ) { return { created: body.username, source: query.source } }}Query String and Form Coercion
Query string parameters and multipart/form-data payloads arrive as strings. Use { coerce: true } to convert string representations into numbers, booleans, dates, or arrays.
Query Array and Date Coercion
import { controller, route } from 'exisjs/router'import { tex } from 'exisjs/validator'
const EventFilterSchema = tex.object({ // Parses ISO 8601 strings (?from=2026-09-01T00:00:00Z) into Date instances from: tex.date({ coerce: true, optional: true }), to: tex.date({ coerce: true, optional: true }),
// Parses comma-separated (?tags=tech,ai) or repeated (?tags=tech&tags=ai) parameters tags: tex.array(tex.string(), { coerce: true, optional: true }),
// Parses comma-separated numeric IDs (?ids=1,2,3 -> [1, 2, 3]) ids: tex.array(tex.number({ coerce: true }), { coerce: true, optional: true }),})
export default controller({ list: route.get('/', { query: EventFilterSchema, handle({ query, res }) { res.json({ filter: query }) }, }),})Form and Multipart Data Coercion
When working with multipart/form-data:
- Single item payloads passed to array fields (e.g.
{ item: { sku: "A1" } }) are coerced into single-element arrays[{ sku: "A1" }]. - JSON-encoded strings submitted within form fields (e.g.
items: "[{\"sku\":\"A1\"}]") are deserialized and validated against nested object/array schemas.
Schema Composition
Reuse and extend existing object schemas using .partial(), .pick(), and .omit().
import { tex } from 'exisjs/validator'import type { ResolveSchema } from 'exisjs/validator'
export const UserSchema = tex.object({ id: tex.string(), name: tex.string({ min: 2 }), email: tex.email(), password: tex.password({ min: 8 }), role: tex.enum(['admin', 'member'], { default: 'member' }),})
// All fields become optionalexport const UpdateUserSchema = UserSchema.partial()
// Retain only specified fieldsexport const LoginSchema = UserSchema.pick(['email', 'password'])
// Exclude sensitive fields for responsesexport const PublicUserSchema = UserSchema.omit(['password'])
export type User = ResolveSchema<typeof UserSchema>export type UpdateUserInput = ResolveSchema<typeof UpdateUserSchema>export type LoginInput = ResolveSchema<typeof LoginSchema>export type PublicUser = ResolveSchema<typeof PublicUserSchema>Data Sanitization
ExisJS allows transformations and sanitizations to run during schema validation or as standalone utility functions via exisjs/sanitize.
Chaining Sanitizers with .sanitize()
import { tex } from 'exisjs/validator'import { sanitize } from 'exisjs/sanitize'
export const CreateArticleSchema = tex.object({ title: tex.string({ min: 5 }).sanitize( sanitize.collapseWhitespace, sanitize.stripHtml ), slug: tex.string().sanitize(sanitize.slugify), content: tex.string().sanitize(sanitize.escapeHtml), tags: tex.array(tex.string()).sanitize( sanitize.dedupe, sanitize.compact ),})Standalone Sanitizer Usage
You can import sanitize directly in services or background jobs:
import { sanitize } from 'exisjs/sanitize'
export function sanitizeTitle(input: string): string { const stripped = sanitize.stripHtml(input) const collapsed = sanitize.collapseWhitespace(stripped) return sanitize.trim(collapsed)}Available Sanitizers Reference
| Category | Function | Description |
|---|---|---|
| Security | sanitize.escapeHtml(val) | Escapes HTML entities (&, <, >, ", '). |
| Security | sanitize.stripHtml(val) | Removes tags, comments, and <script> / <style> elements with their content. |
| Security | sanitize.stripControlChars(val) | Removes invisible control characters and bidirectional overrides. |
| Security | sanitize.preventSql(val) | Rejects common SQL injection probes (the field fails validation). |
| Security | sanitize.preventTraversal(val) | Rejects .. path segments, also percent-encoded, and NUL bytes. |
| Masking | sanitize.maskEmail(val) | Masks email local-parts (e.g. us***@example.com). |
| Masking | sanitize.maskString(val) | Masks characters leaving only boundary characters visible. |
| Formatting | sanitize.trim(val) | Trims leading and trailing whitespace. |
| Formatting | sanitize.toLowerCase(val) | Converts string to lowercase. |
| Formatting | sanitize.toUpperCase(val) | Converts string to uppercase. |
| Formatting | sanitize.collapseWhitespace(val) | Replaces consecutive whitespace with a single space. |
| Formatting | sanitize.slugify(val) | Converts string into URL-friendly slug format. |
| Formatting | sanitize.normalizeEmail(val) | Trims and lowercases an email address. |
| Formatting | sanitize.truncate(length)(val) | Truncates to length characters without splitting an emoji. |
| Numbers | sanitize.round(decimals)(val), sanitize.clamp(min, max)(val) | Rounds; limits to a range. |
| Arrays & Objects | sanitize.dedupe(array) | Removes duplicate elements using a Set. |
| Arrays & Objects | sanitize.compact(array) | Removes null, undefined, and empty string values. |
| Arrays & Objects | sanitize.trimElements(array), sanitize.limitLength(n)(array) | Trims string items; keeps the first n items. |
| Arrays & Objects | sanitize.pick(keys)(obj), sanitize.omit(keys)(obj) | Keeps or removes keys (pick ignores inherited keys). |
| Arrays & Objects | sanitize.deepTrim(value) | Trims every string in a nested object or array. |
Sanitizers run before the field's type check. One that throws, such as preventSql, fails that field with a 400 (code SANITIZATION_FAILED) instead of crashing the request.
Custom and Asynchronous Refinements
Add synchronous validation checks with .refine() or asynchronous database/service checks with .refineAsync().
import { tex } from 'exisjs/validator'import { UserService } from '../../services/user.service'
export const RegisterSchema = tex.object({ email: tex.email().refineAsync(async (email) => { const existing = await UserService.findByEmail(email) return !existing // Returns false if email already exists }, 'Email address is already in use'),
password: tex.password({ min: 8 }), confirmPassword: tex.string(),}).refine((data) => data.password === data.confirmPassword, { message: 'Passwords must match', path: 'confirmPassword',})A field's .refine() and .sanitize() run wherever the field is: in nested objects, array items and record values too. An object's .refine(fn, { message, path }) runs once all its fields are valid and reports the error on path. parseAsync() (used for route validation) also runs .refineAsync() rules at every depth.
To handle failures without try / catch, use safeParse():
const result = RegisterSchema.safeParse(input)if (!result.success) return { errors: result.errors } // [{ path, message, code, ... }]const user = result.dataDiscriminated Unions (tex.discriminatedUnion)
When validating polymorphic payloads where the object structure depends on a discriminator tag field (e.g. event streams, action payloads, webhook types), use tex.discriminatedUnion().
import { tex } from 'exisjs/validator'import type { ResolveSchema } from 'exisjs/validator'
const CreateEvent = tex.object({ type: tex.literal('create'), payload: tex.object({ username: tex.string(), email: tex.email(), }),})
const UpdateEvent = tex.object({ type: tex.literal('update'), payload: tex.object({ id: tex.uuid(), fields: tex.record(tex.any()), }),})
const DeleteEvent = tex.object({ type: tex.literal('delete'), payload: tex.object({ id: tex.uuid(), reason: tex.string({ optional: true }), }),})
export const EventSchema = tex.discriminatedUnion('type', [ CreateEvent, UpdateEvent, DeleteEvent,])
export type WebhookEvent = ResolveSchema<typeof EventSchema>When validating, tex.discriminatedUnion matches the discriminator property against variant schemas. If the discriminator is missing or does not match any registered variant, ExisJS returns an explicit diagnostic error:
{ "success": false, "statusCode": 400, "error": { "code": "VALIDATION_ERROR", "message": "Validation Error", "details": [ { "field": "type", "message": "Invalid discriminator value 'archive' for field 'type'", "expected": "one of [\"create\",\"update\",\"delete\"]", "received": "\"archive\"", "code": "INVALID_DISCRIMINATOR_VALUE", "httpPart": "body" } ] }}Schema Composition (extend, merge, pick, omit)
Derive and reuse schemas across endpoints using composition methods on TexEngine:
1. Extending Schemas (.extend())
Add or override fields on an existing schema:
const BaseUser = tex.object({ id: tex.uuid(), name: tex.string({ min: 2 }), email: tex.email(),})
export const AdminUser = BaseUser.extend({ role: tex.enum(['admin', 'superadmin']), permissions: tex.array(tex.string()),})2. Merging Schemas (.merge())
Combine two independent TexEngine schemas into a unified object definition:
const TimestampSchema = tex.object({ createdAt: tex.date({ coerce: true }), updatedAt: tex.date({ optional: true, coerce: true }),})
const PostContentSchema = tex.object({ title: tex.string({ min: 5 }), body: tex.string(),})
export const FullPostSchema = PostContentSchema.merge(TimestampSchema)3. Picking and Omitting Fields (.pick(), .omit())
Construct derived views for public or partial operations:
// Select only specific fieldsexport const PublicProfile = BaseUser.pick(['name', 'email'])
// Omit internal or sensitive fieldsexport const CreateUser = BaseUser.omit(['id'])Explicit Type Coercion (tex.coerce)
For query strings, path parameters, and headers where HTTP transports deliver values as strings, use the tex.coerce namespace to coerce inputs into numbers, booleans, dates, or strings:
import { controller, route } from 'exisjs/router'import { tex } from 'exisjs/validator'
const ProductFilterQuery = tex.object({ page: tex.coerce.number({ min: 1, default: 1 }), limit: tex.coerce.number({ min: 1, max: 100, default: 20 }), inStock: tex.coerce.boolean({ optional: true }), createdAfter: tex.coerce.date({ optional: true }), category: tex.coerce.string({ optional: true }),})
export default controller({ list: route.get('/', { query: ProductFilterQuery, async handle({ query, res }) { // query.page is number (3) // query.inStock is boolean (true) // query.createdAfter is Date instance res.json({ query }) }, }),})Data Transformations (transform)
Apply post-validation data transformations at both the individual field level and the complete schema level:
Field-Level Transformations (TexType.transform)
export const LoginSchema = tex.object({ email: tex.string().transform((val) => val.toLowerCase().trim()), apiKey: tex.string().transform((key) => Buffer.from(key, 'base64').toString('utf-8')),})Schema-Level Transformations (TexEngine.transform)
export const UserDtoSchema = tex.object({ firstName: tex.string(), lastName: tex.string(), birthYear: tex.number(),}).transform((user) => ({ fullName: `${user.firstName} ${user.lastName}`, age: new Date().getFullYear() - user.birthYear,}))Field Documentation Metadata (describe, example)
Attach human-readable descriptions and example values to schema fields. These are automatically extracted by the ExisJS Swagger / OpenAPI 3.1 generator:
export const CreateOrderSchema = tex.object({ itemSku: tex.string() .describe('Stock keeping unit identifier') .example('SKU-9872-BLU'), quantity: tex.number({ min: 1 }) .describe('Number of units ordered') .example(2),})Use tex.pagination() to standardize query pagination parameters (page, limit, sort, order). Pair it with getPaginationSkip() and paginate() from exisjs/validator.
import { controller, route } from 'exisjs/router'import { tex, paginate, getPaginationSkip } from 'exisjs/validator'
const PaginationQuery = tex.pagination({ defaultLimit: 20, maxLimit: 100,})
export default controller({ list: route.get('/', { query: PaginationQuery, async handle({ query, res }) { const { skip, limit, page } = getPaginationSkip(query) const [items, total] = await Promise.all([ db.items.findMany({ skip, take: limit, orderBy: { [query.sort || 'createdAt']: query.order || 'desc' } }), db.items.count(), ])
res.json(paginate(items, total, { page, limit })) }, }),})Paginated Response Structure
{ "data": [ { "id": "1", "name": "Item 1" } ], "pagination": { "page": 1, "limit": 20, "total": 45, "totalPages": 3, "hasNextPage": true, "hasPrevPage": false, "hasNext": true, "hasPrev": false }}Environment Variable Validation (tex.env)
Use tex.env() in src/config/env.ts to validate environment variables during application startup. It automatically enables type coercion for numbers, booleans, dates, and arrays.
import { tex } from 'exisjs/validator'
export const env = tex.env({ PORT: tex.number({ default: 3000 }), NODE_ENV: tex.enum(['development', 'production', 'test'], { default: 'development' }), DATABASE_URL: tex.string(), JWT_SECRET: tex.string({ min: 32 }), ALLOWED_ORIGINS: tex.array(tex.string()), ENABLE_METRICS: tex.boolean({ default: false }),}).parse(process.env)If any required variable is missing or invalid, tex.env().parse() throws during initialization and logs the error descriptors before the server accepts traffic.
Error Response Format
When incoming requests fail validation, ExisJS returns an HTTP 400 Bad Request with structured error details:
{ "success": false, "statusCode": 400, "error": { "code": "VALIDATION_ERROR", "message": "Validation Error", "details": [ { "field": "username", "message": "Must be at least 4 characters", "expected": "must be at least 4 characters", "received": "\"abc\"", "code": "VALUE_TOO_SMALL", "httpPart": "body" }, { "field": "email", "message": "Must be a valid email", "expected": "valid email address", "received": "\"invalid-email\"", "code": "INVALID_EMAIL", "httpPart": "body" } ] }}Every failing value is listed: each bad item of an array, each bad field of a nested object, up to 100 entries. Common code values: MISSING_REQUIRED_FIELD, INVALID_TYPE, VALUE_TOO_SMALL, VALUE_TOO_LARGE, INVALID_LENGTH, INVALID_FORMAT (pattern), INVALID_EMAIL, INVALID_URL, INVALID_ENUM_VALUE, INVALID_DATE, DATE_TOO_EARLY, DATE_TOO_LATE, ARRAY_TOO_SHORT, ARRAY_TOO_LONG, UNRECOGNIZED_KEYS (strict mode), CUSTOM_VALIDATION (refine), SANITIZATION_FAILED.
In development mode (NODE_ENV=development), ExisJS also outputs a tabular summary of the failed validation constraints to the terminal.
Third-Party Schema Interoperability
ExisJS supports third-party schema libraries (such as Zod, Valibot, and ArkType) via duck typing. Any schema exposing a .parse() or .safeParse() method can be assigned directly to route configurations or decorators.
Using Zod in Functional Routes
import { controller, route } from 'exisjs/router'import { z } from 'zod'
const CreateUserSchema = z.object({ name: z.string().min(3), email: z.string().email(), role: z.enum(['admin', 'member']),})
export default controller({ create: route.post('/', { body: CreateUserSchema, async handle({ body, res }) { res.status(201).json({ success: true, user: body }) }, }),})Query and Param Coercion with Zod
Query strings and path parameters always arrive as strings. For Zod object schemas used as query or params, ExisJS converts fields declared as z.number(), z.boolean() or z.date() before validating, so z.coerce and .transform(Number) are not needed (they keep working if present). This works with Zod 3 and Zod 4, including fields wrapped in .optional(), .default() or .nullable().
const ListQuery = z.object({ page: z.number().int().min(1).default(1), // ?page=2 -> 2 active: z.boolean().optional(), // ?active=true -> true from: z.date().optional(), // ?from=2026-10-01 -> Date})A value that cannot be converted (?page=abc) is left as a string, so the schema rejects it with a normal validation error. Request bodies are never coerced.
Using Zod in OOP Controllers
import { Controller, Post, Body, HttpCode } from 'exisjs/decorators'import { z } from 'zod'
const CreateUserSchema = z.object({ name: z.string().min(3), email: z.string().email(), role: z.enum(['admin', 'member']),})
type CreateUserDto = z.infer<typeof CreateUserSchema>
@Controller('/users')export default class UsersController { @Post('/') @HttpCode(201) async create(@Body(CreateUserSchema) body: CreateUserDto) { return { success: true, user: body } }}ExisJS normalizes error outputs from third-party schema libraries into the standard 400 Bad Request JSON error envelope.