Validation

Standard Schema Validation

Validate query parameters, path params, request bodies, and headers using Zod, ArkType, Valibot, or any Standard Schema library.

Taser natively adheres to the Standard Schema specification (@standard-schema/spec). This means you have zero vendor lock-in. You can use Zod, ArkType, Valibot, or any other compliant validation library without adapters or wrappers.


Path Parameters (.params)

Default Behavior

All path parameters defined in your route filenames (e.g. src/routes/users/$id.get.ts or src/routes/orgs/$orgId/repos/$repoId.get.ts) are automatically inferred as string types on ctx.params by default—no configuration required:

src/routes/users/$id.get.ts
import { json } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

export default t.get("/users/:id").handler(async (ctx) => {
  // ctx.params.id is inferred as string by default:
  const user = await ctx.db.getUser(ctx.params.id);
  return json(user);
});

Validating & Coercing Path Parameters

When you need strict validation (such as UUID formats, integer IDs, or enum values), chain .params() with a schema:

src/routes/organizations/$orgId/members/$memberId.get.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

export default t
  .get("/organizations/:orgId/members/:memberId")
  .params(
    z.object({
      orgId: z.string().uuid(),
      memberId: z.coerce.number().int(),
    }),
  )
  .handler(async (ctx) => {
    // ctx.params.orgId is a validated UUID string
    // ctx.params.memberId is a coerced number
    const member = await ctx.db.getMember(ctx.params.orgId, ctx.params.memberId);
    return json(member);
  });

Query Parameters (.query)

HTTP query parameters always arrive from the URL as strings. Use your schema library to coerce numbers, booleans, and dates:

src/routes/products.get.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

export default t
  .get("/products")
  .query(
    z.object({
      search: z.string().optional(),
      category: z.enum(["electronics", "clothing", "books"]).optional(),
      page: z.coerce.number().int().min(1).default(1),
      limit: z.coerce.number().int().min(1).max(100).default(20),
      inStockOnly: z.coerce.boolean().default(false),
    }),
  )
  .handler(async (ctx) => {
    // ctx.query.page is number
    // ctx.query.inStockOnly is boolean
    const results = await ctx.db.searchProducts(ctx.query);
    return json(results);
  });

Request Bodies & Body Modes (.body)

Taser gives you fine-grained control over request body parsing and performance:

  • Zero Overhead: If a route does not call .body(), incoming body parsing is completely skipped.
  • Default JSON Parsing: Calling .body(schema) parses the body as JSON by default.
  • Explicit Body Modes: To parse file uploads, URL-encoded forms, raw text, or binary buffers, provide the mode as the first argument (.body(mode, schema)):
Body ModeMethod SignatureUse CaseContent Types
json (default).body(schema) or .body("json", schema)REST JSON payloadsapplication/json
form.body("form", schema)File uploads and form submissionsmultipart/form-data, application/x-www-form-urlencoded
text.body("text", schema)Raw text, webhook signatures, XMLtext/plain, application/xml
arrayBuffer.body("arrayBuffer", schema)Raw binary buffers, images, protobufsapplication/octet-stream, any binary

JSON Body Example

src/routes/posts.post.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

const CreatePostSchema = z.object({
  title: z.string().min(5).max(200),
  slug: z.string().regex(/^[a-z0-9-]+$/),
  content: z.string().min(20),
  published: z.boolean().default(false),
  tags: z.array(z.string()).max(5).default([]),
});

export default t
  .post("/posts")
  .body(CreatePostSchema)
  .returns({
    201: z.object({ id: z.string(), slug: z.string() }),
    422: z.object({ message: z.string(), issues: z.array(z.unknown()) }),
  })
  .handler(async (ctx) => {
    // ctx.body is inferred directly from CreatePostSchema:
    const newPost = await ctx.db.posts.create({
      data: ctx.body,
    });

    return json({ id: newPost.id, slug: newPost.slug }, { status: 201 });
  });

Validating Multipart Form Data & File Uploads

When handling file uploads or form data, pass "form" as the first argument to .body(). Taser automatically parses form fields and converts binary file uploads into standard JavaScript File objects.

You can validate file presence, file size, and permitted MIME types using your schema validator:

src/routes/users/$id/avatar.post.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];

const AvatarUploadSchema = z.object({
  caption: z.string().optional(),
  avatar: z
    .file("Avatar must be a valid file")
    .refine((file) => file.size <= MAX_FILE_SIZE, "Max image size is 5MB")
    .refine(
      (file) => ACCEPTED_IMAGE_TYPES.includes(file.type),
      "Only .jpg, .png, and .webp formats are supported"
    ),
});

export default t
  .post("/users/:id/avatar")
  .params(z.object({ id: z.string() }))
  .body("form", AvatarUploadSchema) 
  .returns({
    200: z.object({ success: z.boolean(), fileName: z.string(), bytes: z.number() }),
  })
  .handler(async (ctx) => {
    const { avatar, caption } = ctx.body;

    // Access Web Standard File methods directly:
    const fileBuffer = await avatar.arrayBuffer();
    const fileName = avatar.name;
    const fileSize = avatar.size;

    await ctx.db.saveUserAvatar(ctx.params.id, fileBuffer, fileName);

    return json({
      success: true,
      fileName,
      bytes: fileSize,
    });
  });

Multiple File Uploads

When clients upload multiple files under the same form field name, validate them with .body("form", schema) as an array of File objects:

src/routes/documents/upload.post.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

const MultiDocSchema = z.object({
  folderId: z.string(),
  // Handles single or multiple files seamlessly:
  files: z
    .union([z.file(), z.array(z.file())])
    .transform((val) => (Array.isArray(val) ? val : [val])),
});

export default t
  .post("/documents/upload")
  .body("form", MultiDocSchema) 
  .handler(async (ctx) => {
    // ctx.body.files is typed as File[]:
    for (const file of ctx.body.files) {
      const content = await file.text();
      console.log(`Uploaded file: ${file.name} (${file.size} bytes)`);
    }

    return json({ uploadedCount: ctx.body.files.length });
  });

Client-Side Uploads with formBody()

When consuming your multipart endpoints using @taserjs/router-client, wrap your payload with formBody():

import { formBody } from "@taserjs/router-client";
import { api } from "./lib/api";

const selectedFile = fileInput.files[0];

// The client sends multipart/form-data with automatically managed boundary headers:
const response = await api.users._id.avatar.$post({
  param: { id: "user_123" },
  body: formBody({
    caption: "My new profile picture",
    avatar: selectedFile,
  }),
});

Schema Transformations and Inferred Types

Taser respects schema transformations. If your schema takes a string and transforms it into a Date or trimmed string, ctx receives the transformed output type:

src/routes/events.post.ts
import { ok } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

export default t
  .post("/events")
  .body(
    z.object({
      title: z.string().trim(),
      scheduledAt: z
        .string()
        .datetime()
        .transform((str) => new Date(str)),
    }),
  )
  .handler(async (ctx) => {
    // ctx.body.scheduledAt is typed as Date, not string!
    console.log(ctx.body.scheduledAt.getTime());
    return ok();
  });