Zod validation integration

This example shows how to use Zod to replace VextJS's built-in schema-dsl validation engine. By writing a Zod validation plug-in, you can directly use Zod schema in the validate configuration of the route to gain more powerful type inference and verification capabilities.

Why choose Zod?

FeaturesBuilt-in schema-dslZod
Learning CurveLow (DSL String Syntax)Medium (Chained API)
TypeScript type inference❌ None (no need to manually declare generics)✅ Automatic inference z.infer<T>
Complex verificationBasic type + range + enumerationUnion type, recursion, transform, refine, etc.
EcologyExclusive to VextJSWidely used, rich community ecology
Package size0 (framework built-in)~14KB (min+gzip)
Tip

If your project only requires simple parameter verification (string/number/enumeration/email, etc.), the built-in schema-dsl is enough. Zod is more suitable for scenarios that require complex verification logic, deep type inference, or where Zod is already used in the project.

Project structure

zod-validation/
  ├── src/
  │ ├── config/
  │ │ └── default.ts
  │ ├── plugins/
  │ │ └── zod-validator.ts ← Zod validation plug-in
  │ ├── routes/
  │ │ ├── index.ts
  │ │ └── users.ts
  │ ├── services/
  │ │ └── user.ts
  │ ├── schemas/ ← Zod schema definition
  │ │ └── user.ts
  │ └── index.ts
  ├── package.json
  └── tsconfig.json

1. Install dependencies

npx vextjs create zod-validation
cd zod-validation
pnpm add zod

2. Zod verification plug-in

The core idea: replace the built-in schema-dsl verification engine through app.setValidator(). The VextValidator interface requires the implementation of the compile(schema) method, which returns a validation function.

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

/**
 * Zod verification plug-in
 *
 * Replace the built-in schema-dsl validation engine so that the route validate configuration supports field-level Zod schema.
 *
 * How to use:
 * validate: {
 * body: userCreateSchema.shape, // Field-level Zod schema object
 * query: paginationSchema.shape,
 * }
 *
 * Verification process:
 * 1. compile(schema) receives the original schema object
 * 2. Check whether each field of the schema is a ZodType instance
 * 3. If it is Zod schema, use safeParse for verification
 * 4. If not (normal object), fall back to the original validator (schema-dsl compatible)
 */
export default definePlugin({
  name: "zod-validator",
  setup(app) {
    //Save the original validator for fallback
    const originalValidator = app.getValidator();

    app.setValidator({
      compile(schema: Record<string, unknown>) {
        // ── Determine whether the schema is Zod schema ──────────────────
        //
        // Each position of route validate (query/body/param/header) is an independent schema.
        // If a ZodType instance is passed in, use Zod verification.
        // Otherwise fall back to the original schema-dsl validator (to maintain backward compatibility).

        if (schema instanceof ZodType) {
          // ── Zod verification path ─────────────────────────────────
          const zodSchema = schema;

          return (data: unknown) => {
            const result = zodSchema.safeParse(data);

            if (result.success) {
              return {
                valid: true,
                data: result.data,
              };
            }

            //Convert Zod error format to VextJS standard format
            return {
              valid: false,
              errors: result.error.issues.map((issue) => ({
                field: issue.path.join("."),
                message: issue.message,
              })),
            };
          };
        }

        //──Check whether the field of the schema object contains a Zod instance─────────
        //
        //Support validate: { body: { name: z.string(), age: z.number() } }
        // This hybrid mode of "object field is Zod".
        // This writing method is consistent with the public type of RouteOptions.validate.

        const hasZodFields = Object.values(schema).some(
          (v) => v instanceof ZodType,
        );

        if (hasZodFields) {
          // Collect scattered Zod fields as z.object
          const shape: Record<string, ZodType> = {};for (const [key, value] of Object.entries(schema)) {
            if (value instanceof ZodType) {
              shape[key] = value;
            } else {
              //Non-Zod fields, wrapped as z.any()
              shape[key] = z.any();
            }
          }

          const objectSchema = z.object(shape);

          return (data: unknown) => {
            const result = objectSchema.safeParse(data);

            if (result.success) {
              return {
                valid: true,
                data: result.data,
              };
            }

            return {
              valid: false,
              errors: result.error.issues.map((issue) => ({
                field: issue.path.join("."),
                message: issue.message,
              })),
            };
          };
        }

        // ── Fallback to original schema-dsl validator ────────────────────
        return originalValidator.compile(schema);
      },
    });

    app.logger.info("[zod-validator] Zod verification plug-in has been registered");
  },
});
Tip

This plugin is backwards compatible with schema-dsl. You can mix Zod schema and schema-dsl DSL strings in the same project. For non-Zod schema (such as { name: 'string:1-50' }), the plug-in will automatically fall back to the built-in validation engine.

3. Define Zod Schema

Centrally manage Zod schemas in the src/schemas/ directory to facilitate reuse and maintenance.

// src/schemas/user.ts
import { z } from "zod";

//── Basic field schema ────────────────────────────────────

/** User ID (path parameter) */
export const userIdParam = z.object({
  id: z.string().min(1, "ID cannot be empty"),
});

/** Pagination query parameters */
export const paginationQuery = z.object({
  page: z.coerce.number().int().min(1, "The minimum page number is 1").default(1),
  limit: z.coerce.number().int().min(1).max(100, "Maximum 100 items per page").default(10),
  keyword: z.string().optional(),
});

// ── User CRUD schema ────────────────────────────────────

/** Create user */
export const createUserBody = z.object({
  name: z.string().min(1, "The name cannot be empty").max(50, "The name can be up to 50 characters").trim(),
  email: z.string().email("Email format is invalid").toLowerCase(),
  age: z
    .number()
    .int("Age must be an integer")
    .min(0, "Age cannot be a negative number")
    .max(200, "Age cannot exceed 200")
    .optional(),
  role: z
    .enum(["admin", "user", "editor"], {
      errorMap: () => ({ message: "Role must be admin, user or editor" }),
    })
    .default("user"),
  tags: z.array(z.string().min(1).max(20)).max(10, "Maximum 10 tags").optional(),
  profile: z
    .object({
      bio: z.string().max(500, "The maximum length of the introduction is 500 characters").optional(),
      avatar: z.string().url("The avatar must be a valid URL").optional(),
      website: z.string().url("The website must be a valid URL").optional(),
    })
    .optional(),
});

/** Update user (all fields optional) */
export const updateUserBody = createUserBody
  .partial() // All fields become optional
  .omit({ role: true }) // Do not allow role modification through the update interface
  .refine((data) => Object.keys(data).length > 0, {
    message: "At least one field to be updated needs to be provided",
  });

/** Batch operation */
export const batchDeleteBody = z.object({
  ids: z
    .array(z.string().min(1))
    .min(1, "Select at least one user")
    .max(50, "A maximum of 50 items can be deleted at a time"),
});

//──Export inferred type ───────────────────────────────────────

/** Infer TypeScript types from Zod schema */
export type CreateUserInput = z.infer<typeof createUserBody>;
export type UpdateUserInput = z.infer<typeof updateUserBody>;
export type PaginationInput = z.infer<typeof paginationQuery>;
export type BatchDeleteInput = z.infer<typeof batchDeleteBody>;
Tip

z.coerce.number() is Zod's type cast, which will automatically convert the string '10' into the number 10. This is particularly useful for query parameters (which are always strings), consistent with schema-dsl's automatic type conversion behavior.

4. Configuration

// src/config/default.ts
export default {
  port: 3000,
  adapter: "native",
  logger: {
    level: "debug",
    pretty: true,
  },
  cors: {
    enabled: true,
    origins: ["*"],
  },
  response: {
    wrap: true,
    hideInternalErrors: false,
  },
  openapi: {
    enabled: true,
    title: "Zod Validation Example",
    version: "1.0.0",
    description: "VextJS example using Zod for parameter validation",
    securitySchemes: {
      bearerAuth: {
        type: "http",
        scheme: "bearer",
        bearerFormat: "JWT",
      },
    },
    guardSecurityMap: {
      auth: "bearerAuth",
    },
  },
  middlewares: [{ name: "auth" }],
};

5. Routing (using Zod Schema)

// src/routes/users.ts
import { defineRoutes } from "vextjs";
import {
  userIdParam,
  paginationQuery,
  createUserBody,
  updateUserBody,
  batchDeleteBody,
} from "../schemas/user.js";
import type { CreateUserInput, UpdateUserInput } from "../schemas/user.js";

export default defineRoutes((app) => {
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // GET /users/list — paging query
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.get(
    "/list",
    {
      validate: {
        query: paginationQuery, // ← Zod schema
      },
      docs: {
        summary: "User list",
        description: "Query the user list in pages, supporting fuzzy search by name or email.",
        tags: ["user"],
      },
    },
    async (req, res) => {
      // The data returned by req.valid() has passed Zod checksum transform
      // page/limit has been automatically converted from string to number (z.coerce.number())
      // keyword is string | undefined
      const { page, limit, keyword } = req.valid("query");

      const result = await app.services.user.findAll({ page, limit, keyword });
      res.json(result);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // GET /users/:id — Query details
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.get(
    "/:id",
    {
      validate: {
        param: userIdParam, // ← Zod schema
      },
      docs: {
        summary: "Get user details",
        tags: ["user"],
        responses: {
          200: { description: "Query successful" },
          404: { description: "User does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      const user = await app.services.user.findById(id);

      if (!user) {
        app.throw(404, "User does not exist");
      }

      res.json(user);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // POST /users — Create users
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.post(
    "/",
    {
      validate: {
        body: createUserBody, // ← Zod schema
      },
      middlewares: ["auth"],
      docs: {
        summary: "Create user",
        description: "Create a new user. Supports complex nested structures such as tags and profiles.",
        tags: ["user"],
        responses: {
          201: {
            description: "Created successfully",
            example: {
              id: "4",
              name: "Diana",
              email: "diana@example.com",
              role: "user",
              tags: ["developer"],
              profile: { bio: "Hello!" },
            },
          },
          422: { description: "Parameter verification failed" },
          401: { description: "Not authenticated" },
          409: { description: "Email has been registered" },
        },
      },
    },
    async (req, res) => {
      // Zod is automatically:
      // - trim() the name
      // - toLowerCase() for email
      // - Set role default value 'user'
      // - Verified tags array length and element format
      // - Verified the URL format of the profile nested object
      const body = req.valid<CreateUserInput>("body");

      const user = await app.services.user.create(body);
      res.json(user, 201);
    },
  );// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // PUT /users/:id — update user
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.put(
    "/:id",
    {
      validate: {
        param: userIdParam,
        body: updateUserBody, // ← partial() + refine()
      },
      middlewares: ["auth"],
      docs: {
        summary: "Update user",
        description:
          "Update user information. All fields are optional, but at least one field is required. Modification of roles is not allowed.",
        tags: ["user"],
        responses: {
          200: { description: "Update successful" },
          422: { description: "Parameter validation failed (or no fields were provided)" },
          401: { description: "Not authenticated" },
          404: { description: "User does not exist" },
          409: { description: "The mailbox is already used by another user" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      // updateUserBody uses .partial().omit({ role: true }).refine(...)
      // Zod ensures that there is at least one field and does not contain role
      const body = req.valid<UpdateUserInput>("body");

      const user = await app.services.user.update(id, body);
      res.json(user);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // DELETE /users/:id — delete a user
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.delete(
    "/:id",
    {
      validate: {
        param: userIdParam,
      },
      middlewares: ["auth"],
      docs: {
        summary: "Delete user",
        tags: ["user"],
        responses: {
          204: { description: "Delete successfully" },
          401: { description: "Not authenticated" },
          404: { description: "User does not exist" },
        },
      },
    },
    async (req, res) => {
      const { id } = req.valid("param");
      await app.services.user.delete(id);
      res.status(204).json(null);
    },
  );

  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  // POST /users/batch-delete — batch delete
  // ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
  app.post(
    "/batch-delete",
    {
      validate: {
        body: batchDeleteBody, // ← array verification
      },
      middlewares: ["auth"],
      docs: {
        summary: "Delete users in batches",
        description: "Delete multiple users in batches. Up to 50 users at a time.",
        tags: ["user"],
        responses: {
          200: { description: "Delete results in batches" },
          422: { description: "Parameter verification failed" },
          401: { description: "Not authenticated" },
        },
      },
    },
    async (req, res) => {
      const { ids } = req.valid("body");

      const results = {
        deleted: [] as string[],
        notFound: [] as string[],
      };

      for (const id of ids) {
        try {
          await app.services.user.delete(id);
          results.deleted.push(id);
        } catch {
          results.notFound.push(id);
        }
      }

      res.json(results);
    },
  );
});

6. Service layer

// src/services/user.ts
import type { VextApp, VextLogger } from "vextjs";
import type { CreateUserInput, UpdateUserInput } from "../schemas/user.js";

interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
  role: string;
  tags?: string[];
  profile?: {
    bio?: string;
    avatar?: string;
    website?: string;
  };
  createdAt: string;
  updatedAt: string;
}

export default class UserService {
  private logger: VextLogger;
  private users: Map<string, User> = new Map();
  private nextId = 1;

  constructor(private app: VextApp) {
    this.logger = app.logger.child({ service: "UserService" });
    this.seed();
  }private seed(): void {
    const seedUsers: Partial<User>[] = [
      {
        name: "Alice",
        email: "alice@example.com",
        age: 28,
        role: "admin",
        tags: ["Administrator"],
      },
      { name: "Bob", email: "bob@example.com", age: 32, role: "user" },
      {
        name: "Charlie",
        email: "charlie@example.com",
        role: "editor",
        profile: { bio: "edit" },
      },
    ];

    for (const u of seedUsers) {
      const id = String(this.nextId++);
      const now = new Date().toISOString();
      this.users.set(id, { id, ...u, createdAt: now, updatedAt: now } as User);
    }
  }

  async findAll(options: { page: number; limit: number; keyword?: string }) {
    let allUsers = Array.from(this.users.values());

    if (options.keyword) {
      const kw = options.keyword.toLowerCase();
      allUsers = allUsers.filter(
        (u) =>
          u.name.toLowerCase().includes(kw) ||
          u.email.toLowerCase().includes(kw),
      );
    }

    const total = allUsers.length;
    const start = (options.page - 1) * options.limit;
    const items = allUsers.slice(start, start + options.limit);

    return {
      items,
      total,
      page: options.page,
      limit: options.limit,
      totalPages: Math.ceil(total/options.limit),
    };
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async create(data: CreateUserInput): Promise<User> {
    // Email uniqueness check
    for (const user of this.users.values()) {
      if (user.email === data.email) {
        this.app.throw(409, "Email has been registered", 10001);
      }
    }

    const id = String(this.nextId++);
    const now = new Date().toISOString();

    const user: User = {
      ID,
      name: data.name,
      email: data.email,
      age: data.age,
      role: data.role,
      tags: data.tags,
      profile: data.profile,
      createdAt: now,
      updatedAt: now,
    };

    this.users.set(id, user);
    this.logger.info({ userId: id, email: data.email }, "User created successfully");
    return user;
  }

  async update(id: string, data: UpdateUserInput): Promise<User> {
    const user = this.users.get(id);
    if (!user) {
      this.app.throw(404, "User does not exist");
    }

    if (data.email && data.email !== user.email) {
      for (const u of this.users.values()) {
        if (u.email === data.email) {
          this.app.throw(409, "The mailbox is already used by another user", 10002);
        }
      }
    }

    const updated: User = {
      ...user,
      ...data,
      // Deep merge profile
      profile: data.profile
        ? { ...user.profile, ...data.profile }
        : user.profile,
      updatedAt: new Date().toISOString(),
    };

    this.users.set(id, updated);
    return updated;
  }

  async delete(id: string): Promise<void> {
    if (!this.users.has(id)) {
      this.app.throw(404, "User does not exist");
    }
    this.users.delete(id);
  }
}

7. Entry file

// src/index.ts
import { bootstrap } from "vextjs";

bootstrap().catch((err) => {
  console.error("Startup failed:", err);
  process.exit(1);
});

8. Operation and verification

# Start development server
pnpmdev

Test verification

# ✅ Normal creation (Zod automatically trim name + toLowerCase email)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "name": "Diana",
    "email": "Diana@EXAMPLE.COM",
    "age": 25,
    "tags": ["developer", "designer"],
    "profile": { "bio": "Full-stack Developer", "website": "https://diana.dev" }
  }'
# → 201
# name is trimmed to "Diana"
# email is toLowerCase to "diana@example.com"
# role is set to "user" by default# ❌ Nested object validation — profile.avatar non-URL
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "name": "Eve",
    "email": "eve@example.com",
    "profile": { "avatar": "not-a-url" }
  }'
# → 422 {"errors":[{"field":"profile.avatar","message":"Avatar must be a valid URL"}]}

# ❌ Array length check — more than 10 tags
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{
    "name": "Eve",
    "email": "eve@example.com",
    "tags": ["1","2","3","4","5","6","7","8","9","10","11"]
  }'
# → 422 {"errors":[{"field":"tags","message":"Max 10 tags"}]}

# ❌ refine validation — no fields were provided when updating
curl -X PUT http://localhost:3000/users/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{}'
# → 422 {"errors":[{"field":"","message":"At least one field to be updated is required"}]}

# ❌ omit verification — attempt to modify role during update (removed by omit)
curl -X PUT http://localhost:3000/users/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"role": "admin", "name": "Alice Updated"}'
# → 200 but the role field is ignored (Zod strip unknown field), only name is updated

# ❌ Enumeration validation — invalid role value
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"name":"Frank","email":"frank@example.com","role":"superadmin"}'
# → 422 {"errors":[{"field":"role","message":"Role must be admin, user or editor"}]}

#✅ Query parameter automatic type conversion (z.coerce.number)
curl "http://localhost:3000/users/list?page=2&limit=5"
# → page automatically converts from the string "2" to the number 2

# ✅ Batch delete
curl -X POST http://localhost:3000/users/batch-delete \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer user-1-admin" \
  -d '{"ids": ["1", "2", "999"]}'
# → {"code":0,"data":{"deleted":["1","2"],"notFound":["999"]},...}

Zod Advanced Tips

Transform (data conversion)

import { z } from "zod";

// Query parameters: comma separated string → array
const searchQuery = z.object({
  ids: z
    .string()
    .transform((val) => val.split(",").filter(Boolean))
    .pipe(z.array(z.string().min(1)).min(1))
    .optional(),
  sort: z
    .string()
    .regex(/^[a-zA-Z]+:(asc|desc)$/, "Sort format: field:asc or field:desc")
    .transform((val) => {
      const [field, order] = val.split(":");
      return { field, order: order as "asc" | "desc" };
    })
    .optional(),
});

// use
app.get("/search", { validate: { query: searchQuery } }, async (req, res) => {
  const { ids, sort } = req.valid("query");
  // ids: string[] | undefined
  // sort: { field: string, order: 'asc' | 'desc' } | undefined
});

Discriminated Union (discriminate union type)

import { z } from "zod";

// Use different validation rules based on the type field
const notificationBody = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("email"),
    to: z.string().email(),
    subject: z.string().min(1).max(200),
    body: z.string().min(1),
  }),
  z.object({
    type: z.literal("sms"),
    phone: z.string().regex(/^\+?[\d\s-]{10,15}$/),
    message: z.string().min(1).max(160),
  }),
  z.object({
    type: z.literal("push"),
    deviceToken: z.string().min(1),
    title: z.string().min(1).max(100),
    body: z.string().min(1).max(500),
  }),
]);

app.post("/notify", { validate: { body: notificationBody } }, handler);

Preprocess (preprocessing)

import { z } from "zod";// Handle "true" / "false" string to boolean conversion (commonly used in query parameters)
const filterQuery = z
  .object({
    active: z.preprocess((val) => {
      if (val === "true") return true;
      if (val === "false") return false;
      return val;
    }, z.boolean().optional()),
    minAge: z.coerce.number().int().min(0).optional(),
    maxAge: z.coerce.number().int().max(200).optional(),
  })
  .refine(
    (data) => {
      if (data.minAge !== undefined && data.maxAge !== undefined) {
        return data.minAge <= data.maxAge;
      }
      return true;
    },
    { message: "minAge must be less than or equal to maxAge", path: ["minAge"] },
  );

Reuse and combination

import { z } from "zod";

//Basic schema
const addressSchema = z.object({
  street: z.string().min(1).max(200),
  city: z.string().min(1).max(50),
  state: z.string().min(1).max(50),
  zip: z.string().regex(/^\d{6}$/, "Zip code must be 6 digits"),
  country: z.string().min(1).max(50).default("China"),
});

// Use in combination
const orderSchema = z.object({
  items: z
    .array(
      z.object({
        productId: z.string().uuid(),
        quantity: z.number().int().min(1).max(999),
        price: z.number().positive(),
      }),
    )
    .min(1, "At least one product is required"),
  shippingAddress: addressSchema,
  billingAddress: addressSchema.optional(), // Optional, reuse the same schema
  note: z.string().max(500).optional(),
});

//Infer type from existing schema
type Order = z.infer<typeof orderSchema>;
type Address = z.infer<typeof addressSchema>;

Mixed with schema-dsl

The Zod plugin supports using Zod and schema-dsl simultaneously in the same project:

// This route uses Zod
app.post(
  "/users",
  {
    validate: {
      body: createUserBody.shape, // ← field level Zod schema
      query: paginationQuery.shape, // ← field level Zod schema
    },
  },
  handler,
);

// This route uses schema-dsl (automatic fallback)
app.get(
  "/health",
  {
    validate: {
      query: {
        verbose: "boolean?", // ← schema-dsl string
      },
    },
  },
  handler,
);

The plug-in will automatically detect the schema type internally: when the field value is a Zod instance, the Zod verification path will be used, and for ordinary schema-dsl objects, the default verification path will be used.

Key comparison

sceneschema-dslZod
Simple field verificationname: 'string:1-50'z.string().min(1).max(50)
Optional fieldsage: 'number?'z.number().optional()
Enumerationrole: 'enum:admin,user'z.enum(['admin', 'user'])
Emailemail: 'email'z.string().email()
Nested objects❌ Not supportedz.object({ profile: z.object({...}) })
Array verificationtags: 'array'z.array(z.string()).max(10)
Union type❌ Not supportedz.union([...]) / z.discriminatedUnion(...)
Custom validation❌ Not supported.refine() / .superRefine()
Data conversionType conversion only.transform() / .preprocess()
Default value❌ Not supported.default()
Type inference❌ Needs to be declared manuallyz.infer<typeof schema>

Next step