Parameter verification

VextJS integrates schema-dsl to provide declarative parameter verification. Use concise DSL strings to describe verification rules in the route options.validate. The framework automatically completes verification, type conversion, and generates OpenAPI documents synchronously.

Basic usage

In the three-part definition of the route, the validation rules are declared through the validate field:

import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.post(
    "/users",
    {
      validate: {
        body: {
          name: "string:1-50!", // Required string, length 1-50
          email: "email!", // required, email format
          age: "number?", // optional number
          role: "admin|user", // enumeration value
        },
      },
      docs: { summary: "Create user" },
    },
    async (req, res) => {
      // req.body has passed verification + type conversion
      const data = req.valid("body");
      const user = await app.services.user.create(data);
      res.json(user, 201);
    },
  );
});

After passing the verification, obtain the type-converted data through req.valid(location). When the verification fails, the framework automatically returns a 422 error response, without manual processing.

Check location

validate supports four locations, corresponding to different data sources requested:

LocationData SourceDescription
paramreq.paramsPath dynamic parameters (such as /:id)
queryreq.queryURL query parameters (such as ?page=1)
headerreq.headersRequest headers
bodyreq.bodyRequest body (JSON/URL-encoded)

The verification is executed in the order of paramqueryheaderbody. If the verification fails at any position, an error will be returned immediately.

app.put(
  "/users/:id",
  {
    validate: {
      param: {
        id: "string!",
      },
      query: {
        fields: "string?", // Optional, specify the return field
      },
      header: {
        "x-api-version": "string?",
      },
      body: {
        name: "string:1-50?",
        email: "email?",
      },
    },
  },
  async (req, res) => {
    const { id } = req.valid("param");
    const body = req.valid("body");
    const user = await app.services.user.update(id, body);
    res.json(user);
  },
);

::::tip note The singular param is used in validate (corresponding to the concept of path parameters), but the underlying data source is req.params (plural). The mapping has been done correctly inside the framework, you don’t need to worry about it. ::::

Detailed explanation of DSL syntax

schema-dsl uses concise string expressions to describe data types and constraints.

Basic types

DSL expressionMeaningExample values
`'string''string"hello"
'number'Number42, 3.14
'boolean'Boolean valuetrue, false
`'email''Email format"user@example.com"
'url'URL format"https://example.com"
`'date''Date string"2026-01-15"

Required and optional

Add a ! or ? tag at the end of the type expression:

SuffixMeaningExample
!required`'string!'' — required string
?Optional`'string?'' — Optional string
no suffixoptional (default)'string' — equivalent to 'string?'
validate: {
  body: {
    name: 'string!', // required
    nickname: 'string?', // optional
    bio: 'string', // optional (equivalent to 'string?')
  },
}

Scope constraints

Use the :min-max syntax to specify a range:

String length

"string:1-50"; // length 1 to 50
"string:1-50!"; // Required, length 1 to 50
"string:5-"; // Minimum length 5, no upper limit
"string:-100"; // Maximum length 100

Number range

"number:1-100"; // Value range 1 to 100
"number:0-"; // minimum value 0 (non-negative number)
"number:1-"; // Minimum value 1 (positive integer/positive number)
"number:-999"; // Maximum value 999
"number:18-120!"; // required, range 18 to 120

Enumeration value

Use | to separate enumeration options:

"admin|user|guest"; // Enumeration: admin / user / guest
"draft|published|archived"; // Enumeration: draft / published / archived
"male|female|other"; // Enumeration: male / female / other

Enumeration values are always of type string. Maps as enum in OpenAPI documentation.

Combination example

validate: {
  body: {
    //Basic type + required/optional
    username: 'string:3-30!', // Required string, length 3-30
    password: 'string:8-128!', // Required string, length 8-128
    email: 'email!', // required email
    website: 'url?', // optional URL
    age: 'number:0-150?', // optional number, range 0-150
    score: 'number:0-100', // optional number, range 0-100
    active: 'boolean!', // required Boolean value
    role: 'admin|editor|viewer', // enumeration
    birthday: 'date?', // optional date
  },
}

Type conversion

schema-dsl automatically performs type conversion during validation, which is especially useful for query and param data (their original values are always strings):

declared typeoriginal valueconverted
'number'"42"42
'number'"3.14"3.14
'boolean'"true"true
'boolean'"false"false
`'boolean''"1"true
'boolean'"0"false
app.get(
  "/search",
  {
    validate: {
      query: {
        page: "number:1-", // ?page=3 → number 3 (not the string "3")
        limit: "number:1-100", // ?limit=20 → number 20
        active: "boolean", // ?active=true → boolean true
      },
    },
  },
  async (req, res) => {
    const { page, limit, active } = req.valid("query");
    // page: number, limit: number, active: boolean — automatically converted
    res.json({ page, limit, active });
  },
);

Get the verified data

req.valid(location)

Use req.valid() to get the data after checksum type conversion. It can only be called after the corresponding location is configured in validate.

app.post(
  "/orders",
  {
    validate: {
      body: {
        productId: "string!",
        quantity: "number:1-99!",
      },
      query: {
        coupon: "string?",
      },
    },
  },
  async (req, res) => {
    const body = req.valid("body"); // { productId: string, quantity: number }
    const query = req.valid("query"); // { coupon?: string }

    const order = await app.services.order.create(body, query.coupon);
    res.json(order, 201);
  },
);

Boundary behavior

::::warning Notes

req.valid(location) has the following boundary behavior to be aware of:

  1. Called when validate is not configured

    If the route is not configured with a validate field, calling req.valid("body") will return undefined. The framework won't throw an error, but you won't be able to get the data after the checksum typecast.

  2. location is not declared in validate

    If only body is declared in validate, but req.valid("query") is called, undefined will also be returned. Only locations explicitly declared in validate will have verified data.

  3. Handler will not be reached when verification fails

    When the verification fails, the framework will automatically return a 422 error response before the handler is executed, so the data must have passed the verification when calling req.valid() inside the handler.

//Boundary case example
app.get(
  "/items",
  {
    validate: {
      query: { page: "number:1-" },
      // body is not declared
    },
  },
  async (req, res) => {
    const query = req.valid("query"); // { page: number }, verified
    const body = req.valid("body"); // undefined, not declared in validate
    const param = req.valid("param"); // undefined, not declared in validate
    res.json({ query });
  },
);

// The route of validate is not configured
app.get("/health", async (req, res) => {
  const body = req.valid("body"); // undefined, the route is not configured validate
  res.json({ status: "ok" });
});

Best Practice: Always ensure that the location of req.valid(location) is consistent with the location declared in validate. ::::

Generic type hints

You can use generics to get more precise IDE type hints:

interface CreateUserBody {
  name: string;
  email: string;
  age?: number;
}

app.post(
  "/users",
  {
    validate: {
      body: {
        name: "string:1-50!",
        email: "email!",
        age: "number:0-150?",
      },
    },
  },
  async (req, res) => {
    const data = req.valid<CreateUserBody>("body");
    // data.name — which the IDE knows is a string
    // data.email — the IDE knows it is a string
    // data.age — the IDE knows that number | undefined
    res.json(await app.services.user.create(data));
  },
);

Verification error response

When the verification fails, the framework automatically returns a structured error response with a 422 status code:

{
  "code": 422,
  "message": "Validation failed",
  "errors": [
    {
      "field": "email",
      "message": "must be a valid email address"
    },
    {
      "field": "name",
      "message": "length must be between 1 and 50"
    }
  ],
  "requestId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
  • code: HTTP status code 422 (Unprocessable Entity)
  • message: fixed to "Validation failed"
  • errors: field-level error array, including field (field name) and message (error description)
  • requestId: the unique identifier of the current request

Validation errors are handled uniformly by the framework's global error handler, and you do not need to manually try-catch in routing.

Linkage with OpenAPI documentation

DSL rules in validate are automatically mapped to the parameters and requestBody definitions of the OpenAPI document. No additional configuration is required, the verification rules are the document rules:

app.get(
  "/users",
  {
    validate: {
      query: {
        page: "number:1-",
        limit: "number:1-100",
        status: "active|inactive|banned",
      },
    },
    docs: { summary: "Get user list" },
  },
  handler,
);

The above route will be automatically generated in the OpenAPI documentation:

  • page — query parameter, type: integer, minimum: 1
  • limit — query parameter, type: integer, minimum: 1, maximum: 100
  • status — query parameter, type: string, enum: ["active", "inactive", "banned"]

Visit /docs to view the automatically generated parameter documentation and built-in "Try it out" functionality in the Scalar documentation.

If you want the OpenAPI document to display the business meaning of the field, you can append .description() after the field DSL:

app.post(
  "/translate",
  {
    validate: {
      body: {
        content: "string:1-20000!".description(
          "Text to be translated, length 1-20000 characters",
        ),
        targetLanguages: [
          {
            code: "string:1-64!".description("target language code"),
          },
        ],
        format: "enum:plain_text,preserve_line_breaks".description("output format"),
      },
    },
    docs: { summary: "Perform text translation" },
  },
  handler,
);

The generated OpenAPI schema will retain these descriptions, while continuing to retain constraints such as required, enum, minLength, maxLength, etc. Fields without a handwritten description will still use the abstract description generated by the framework.

Advanced usage

Multi-position combination verification

The same route can verify multiple locations at the same time:

app.put(
  "/users/:id/avatar",
  {
    validate: {
      param: { id: "string!" },
      header: { "content-type": "string!" },
      query: { size: "number:32-512?" },
      body: { url: "url!", alt: "string:0-200?" },
    },
  },
  async (req, res) => {
    const { id } = req.valid("param");
    const { url, alt } = req.valid("body");
    const { size } = req.valid("query");

    await app.services.user.updateAvatar(id, { url, alt, size });
    res.json({ success: true });
  },
);

Cooperate with routing-level middleware

The verification middleware is executed after the routing-level middleware and before the handler. This means:

Request → [global middleware] → [routing-level middleware: auth, check-role] → [validate verification] → [handler]

Authentication check is performed before parameter verification, and unauthenticated requests will not trigger the verification logic:

app.post(
  "/admin/users",
  {
    middlewares: [
      "auth",
      { name: "check-role", options: { roles: ["admin"] } },
    ],
    validate: {
      body: {
        name: "string:1-50!",
        email: "email!",
        role: "admin|editor|viewer!",
      },
    },
  },
  handler,
);

Route override current limiting rules

In addition to parameter verification, options also supports route-level configuration override (override), which can adjust current limiting, timeout and other settings for specific routes:

app.post(
  "/login",
  {
    validate: {
      body: {
        email: "email!",
        password: "string:8-128!",
      },
    },
    override: {
      rateLimit: { max: 5, window: 60 }, // Maximum 5 times per minute (window unit: seconds)
    },
  },
  handler,
);

app.get(
  "/public/health",
  {
    override: {
      rateLimit: false, // Health check does not limit the flow
    },
  },
  handler,
);

Reuse the verification engine in the service layer

For route entry parameters, RouteOptions.validate + req.valid() is preferred. If the service also needs to verify non-HTTP input, such as scheduled tasks, message queues, external callbacks, or internal DTOs, you can obtain the current global validation engine through this.app.getValidator().

getValidator() returns the current validator: implemented by schema-dsl by default; if the plug-in is replaced by Zod, Yup, etc. through app.setValidator(), the service will also get the replaced validator.It is recommended to throw VextValidationError directly, so that the framework will return a structured 422 response and an errors array. Don't write this type of validation failure as a normal throw new Error("..."), otherwise it will be treated as an unknown exception and enter the 500 path.

import { VextValidationError, type VextApp, type VextValidator } from "vextjs";

const createUserSchema = {
  name: "string:1-50!",
  email: "email!",
};

export default class UserService {
  private validateCreateUser: ReturnType<VextValidator["compile"]>;

  constructor(private app: VextApp) {
    const validator = app.getValidator();
    this.validateCreateUser = validator.compile(createUserSchema);
  }

  async create(input: unknown) {
    const result = this.validateCreateUser(input);

    if (!result.valid) {
      throw new VextValidationError(result.errors ?? []);
    }

    const data = result.data as { name: string; email: string };
    // Continue executing business logic...
    return data;
  }
}

::::tip

Do not directly import "schema-dsl" in business services. Directly relying on schema-dsl will bypass the global replacement capability of app.setValidator(), and will also cause route verification and service verification to use different engines.

::::

Replace verification engine

VextJS uses schema-dsl as the validation engine by default. If you prefer third-party verification libraries such as Zod and Yup, you can replace the built-in verification engine through plug-ins.

Using Zod Example

// src/plugins/zod-validator.ts
import { definePlugin } from "vextjs";
import type { VextValidator } from "vextjs";
import { z } from "zod";

export default definePlugin({
  name: "zod-validator",

  setup(app) {
    const originalValidator = app.getValidator();

    const zodValidator: VextValidator = {
      compile(schema) {
        const toVextResult = (result: ReturnType<z.ZodType["safeParse"]>) =>
          result.success
            ? { valid: true, data: result.data }
            : {
                valid: false,
                errors: result.error.issues.map((issue) => ({
                  field: issue.path.join("."),
                  message: issue.message,
                })),
              };

        // Use Zod verification when the entire location schema is Zod schema
        if (schema instanceof z.ZodType) {
          return (data) => toVextResult(schema.safeParse(data));
        }

        // The current RouteOptions.validate type also supports field-level Zod schema
        const zodShape: Record<string, z.ZodType> = {};
        for (const [key, value] of Object.entries(schema)) {
          if (value instanceof z.ZodType) {
            zodShape[key] = value;
          }
        }

        if (Object.keys(zodShape).length > 0) {
          const zodSchema = z.object(zodShape);
          return (data) => toVextResult(zodSchema.safeParse(data));
        }

        // Otherwise fall back to default schema-dsl behavior
        return originalValidator.compile(schema);
      },
    };

    app.setValidator(zodValidator);
    app.logger.info("Zod validator plugin activated");
  },
});

After replacement, validate in routes can be passed in field-level Zod schema objects instead of DSL strings.

Common patterns

Pagination query

app.get(
  "/posts",
  {
    validate: {
      query: {
        page: "number:1-",
        limit: "number:1-100",
        sort: "createdAt|updatedAt|title",
        order: "asc|desc",
      },
    },
  },
  async (req, res) => {
    const {
      page = 1,
      limit = 20,
      sort = "createdAt",
      order = "desc",
    } = req.valid("query");
    const posts = await app.services.post.findAll({ page, limit, sort, order });
    res.json(posts);
  },
);

Search filter

app.get(
  "/products",
  {
    validate: {
      query: {
        keyword: "string?",
        category: "string?",
        minPrice: "number:0-?",
        maxPrice: "number:0-?",
        inStock: "boolean?",
      },
    },
  },
  async (req, res) => {
    const filters = req.valid("query");
    const products = await app.services.product.search(filters);
    res.json(products);
  },
);

User registration

app.post(
  "/auth/register",
  {
    validate: {
      body: {
        username: "string:3-30!",
        email: "email!",
        password: "string:8-128!",
        confirmPassword: "string:8-128!",
      },
    },
    override: {
      rateLimit: { max: 3, window: 60 }, // Unit: seconds
    },
  },
  async (req, res) => {
    const data = req.valid("body");

    if (data.password !== data.confirmPassword) {
      app.throw(400, "Two passwords are inconsistent");
    }

    const user = await app.services.auth.register(data);
    res.json(user, 201);
  },
);

File path parameters

// src/routes/files/[id].ts
app.get(
  "/",
  {
    validate: {
      param: { id: "string!" },
      query: { download: "boolean?" },
    },
  },
  async (req, res) => {
    const { id } = req.valid("param");
    const { download } = req.valid("query");

    const file = await app.services.file.findById(id);
    if (!file) app.throw(404, "file.not_found");

    if (download) {
      res.download(file.stream, file.name, file.contentType);
    } else {
      res.json(file.metadata);
    }
  },
);

Best Practices

1. Always use req.valid() instead of req.body

Routes configured with validate should use req.valid('body') instead of directly accessing req.body:

// ✅ Correct — use verified data
const data = req.valid("body");

// ❌ Avoid — type conversion skipped
const data = req.body;

The data returned by req.valid() has been type converted (such as "42"42 in query), and direct access to req.body / req.query is the original data.

2. Reasonable use of required tags

For query and header positions, usually use optional (?); for core fields in body, use required (!):

validate: {
  query: {
    page: 'number:1-', // optional (paging has default value)
    keyword: 'string?', // optional (search keyword)
  },
  body: {
    name: 'string:1-50!', // required (must be provided when creating a resource)
    email: 'email!', // required
    bio: 'string:0-500?', // optional
  },
}

3. Verification rules are documents

Since validation rules are automatically mapped to OpenAPI documents, writing validate is equivalent to writing the interface document. Describe the constraints as precisely as possible:

// ✅ Precise constraints — clear documentation and validation
validate: {
  body: {
    username: 'string:3-30!', // 3-30 characters, required
    age: 'number:0-150?', // 0-150, optional
    role: 'admin|editor|viewer!', // Explicit enumeration
  },
}

// ❌ Broad constraints — insufficient documentation information
validate: {
  body: {
    username: 'string!', // No length constraint
    age: 'number?', // no range constraints
    role: 'string!', // Enumeration should be used
  },
}

4. Use app.throw() for custom validation in Handler

DSL syntax cannot cover all verification scenarios (such as cross-field verification, database uniqueness checking). For these scenarios, use app.throw() in the handler or service to throw manually:

app.post(
  "/users",
  {
    validate: {
      body: { email: "email!", password: "string:8-128!" },
    },
  },
  async (req, res) => {
    const data = req.valid("body");

    // Database uniqueness check - DSL cannot override
    const existing = await app.services.user.findByEmail(data.email);
    if (existing) {
      app.throw(409, "Email has been registered", 10001);
    }

    const user = await app.services.user.create(data);
    res.json(user, 201);
  },
);

Next step

  • Understand the global configuration related to verification in Configuration
  • View OpenAPI Documentation how to link with verification rules
  • Learn the complete usage of the three-stage expression in Routing
  • Explore plugins how to replace the validation engine