Routing System

Defining Routes

Learn how to define type-safe route endpoints using Taser's fluent route builder. Chain query, params, body schemas, middlewares, and response contracts.

Every route file in src/routes/ exports a Route constant created by invoking the router instance builder (t).


Route Builders

The t router instance provides fluent builder methods corresponding to all standard HTTP methods:

t.get(path);
t.post(path);
t.put(path);
t.patch(path);
t.delete(path);
t.options(path);
t.head(path);
t.query(path);
t.any(path, methods);
t.all(path);

Fluent Chaining Methods

Each builder method returns a RouteBuilder that supports fluent method chaining:

MethodDescription
.query(schema)Validates query string parameters
.params(schema)Validates and coerces path parameters
.body(schema)Validates request payloads (JSON by default)
.body(mode, schema)Validates payloads with explicit mode (json, form, text, raw)
.returns({ [status]: schema })Enforces compile-time and runtime response contracts
.use(middleware)Attaches route-level middleware
.handler(fn)Terminal method executing the route logic

Defining a GET Route

GET, DELETE, OPTIONS, and HEAD routes accept validation schemas for query and params:

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

const GET = t
  .get("/articles")
  .query(
    z.object({
      category: z.string().optional(),
      page: z.coerce.number().int().min(1).default(1),
      limit: z.coerce.number().int().min(1).max(50).default(20),
    }),
  )
  .returns({
    200: z.object({
      articles: z.array(
        z.object({
          id: z.string(),
          title: z.string(),
          category: z.string(),
        }),
      ),
      page: z.number(),
      total: z.number(),
    }),
  });

export type RouteContext = typeof GET.$Infer.Context;
export default GET.handler(async (ctx) => {
  const articles = await ctx.db.getArticles(ctx.query);
  return json({
    articles,
    page: ctx.query.page,
    total: 100,
  });
});

Path Parameters & Type Precedence

Path parameters in dynamic routes (such as /tasks/:id or /orgs/:orgId/users/:id) are automatically inferred as string on ctx.params.

When you supply a .params() schema, the validated schema types take precedence and override the default string types with full type coercion:

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

export default t
  .get("/tasks/:id")
  .params(
    z.object({
      id: z.coerce.number(), // ctx.params.id is coerced to number
    }),
  )
  .handler(async (ctx) => {
    const task = await ctx.db.getTaskById(ctx.params.id); // ctx.params.id is number
    return json(task);
  });

Any path parameter not explicitly mentioned in the .params() schema retains its inferred string type (for example, /orgs/:orgId/tasks/:id preserves ctx.params.orgId as string).


Defining a POST Route

POST, PUT, PATCH, and QUERY routes accept .body(), .query(), and .params() schemas:

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

const POST = t
  .post("/articles")
  .body(
    z.object({
      title: z.string().min(3).max(120),
      content: z.string().min(10),
      tags: z.array(z.string()).default([]),
    }),
  )
  .returns({
    201: z.object({
      id: z.string(),
      title: z.string(),
      createdAt: z.string(),
    }),
    400: z.object({ message: z.string() }),
  });

export type RouteContext = typeof POST.$Infer.Context;
export default POST.handler(async (ctx) => {
  const article = await ctx.db.createArticle(ctx.body);
  return created(article);
});

JSON & Multipart / File Uploads

The .body() method validates application/json by default and also supports form, text, and raw modes. See the Standard Schema Validation Guide for examples on validating File uploads, sizes, and MIME types.


Route-Level Middlewares

In addition to directory layout middlewares, you can attach route-specific middleware using .use():

src/routes/admin/purge.delete.ts
import { noContent } from "@taserjs/router/reply";
import { t } from "@taserjs/router";
import { verifySuperAdmin } from "../../middleware/super-admin";
import { rateLimit } from "../../middleware/rate-limit";

export default t
  .delete("/admin/purge")
  .use(rateLimit({ max: 5, windowMs: 60000 }))
  .use(verifySuperAdmin())
  .handler(async (ctx) => {
    await ctx.db.purgeDeletedRecords();
    return noContent();
  });

Multi-Method Handlers (t.any and t.all)

When an endpoint needs to handle multiple HTTP methods with shared logic, use t.any() or t.all():

src/routes/webhooks/stripe.post.ts
import { json, ok } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

// Accepts GET and POST requests
export default t.any("/webhooks/stripe", ["GET", "POST"]).handler(async (ctx) => {
  if (ctx.method === "GET") {
    return json({ status: "Stripe webhook endpoint active" });
  }

  const payload = ctx.body;
  await handleStripeEvent(payload);
  return ok();
});

Inferred Route Types ($Infer)

Route builders provide an $Infer namespace for extracting the exact compile-time types of your route context, input arguments, and returns:

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

const GET = t
  .get("/users/:id")
  .params(z.object({ id: z.string().uuid() }))
  .query(z.object({ includeProfile: z.coerce.boolean().default(false) }))
  .returns({
    200: z.object({ id: z.string(), name: z.string() }),
    404: z.object({ message: z.string() }),
  });

// Extract context shape (query, params, body, state, singletons):
export type RouteContext = typeof GET.$Infer.Context;

// Extract input arguments shape:
export type RouteInput = typeof GET.$Infer.Input;

// Extract returns shape:
export type RouteOutput = typeof GET.$Infer.Output;

export default GET.handler(async (ctx) => {
  const user = await ctx.db.findUser(ctx.params.id);
  if (!user) {
    return notFound({ message: "User not found" });
  }
  return json(user);
});