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:
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:
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:
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 Mode | Method Signature | Use Case | Content Types |
|---|---|---|---|
json (default) | .body(schema) or .body("json", schema) | REST JSON payloads | application/json |
form | .body("form", schema) | File uploads and form submissions | multipart/form-data, application/x-www-form-urlencoded |
text | .body("text", schema) | Raw text, webhook signatures, XML | text/plain, application/xml |
arrayBuffer | .body("arrayBuffer", schema) | Raw binary buffers, images, protobufs | application/octet-stream, any binary |
JSON Body Example
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:
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:
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:
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();
});Related Guides
Extracting Helper Functions
Extract reusable route handlers and split large API endpoints into small, testable modules with Taser compile-time Context type inference.
Middleware Validation
Validate incoming headers, auth tokens, and session context at the layout level before requests reach downstream REST API route handlers.