Routing System

Extracting Helper Functions

Extract reusable route handlers and split large API endpoints into small, testable modules with Taser compile-time Context type inference.

In production applications, route handlers can easily grow into monolithic blocks of business logic, mixing input validation, database transactions, third-party payment processing, and notification dispatching.

In traditional frameworks, breaking these monolithic handlers into smaller helper functions often forces developers to write verbose, manually duplicated TypeScript interfaces for the request context, params, and state.

Taser makes decomposing handler logic instant and 100% type-safe via the route builder's $Infer.Context namespace.


The Core Pattern

To extract helper functions without repeating type definitions:

  1. Declare the Route Builder Variable First (GET, POST, PUT, etc.).
  2. Extract RouteContext using typeof VERB.$Infer.Context.
  3. Write Small Helper Functions that receive ctx: RouteContext (or a slice of it).
  4. Call .handler() on your route builder and orchestrate the helpers.
src/routes/users/$id.get.ts
import { notFound, ok } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

// 1. Declare the verb builder
const GET = t
  .get("/users/:id")
  .params(z.object({ id: z.string().uuid() }))
  .query(z.object({ includeOrders: z.coerce.boolean().default(false) }));

// 2. Extract the exact compile-time context type
export type RouteContext = typeof GET.$Infer.Context; 

// 3. Extract focused helper functions
async function fetchUser(ctx: RouteContext) {
  return ctx.db.user.findUnique({
    where: { id: ctx.params.id }, // Typed as string (uuid)
  });
}

async function fetchUserOrders(ctx: RouteContext) {
  if (!ctx.query.includeOrders) return []; // Typed as boolean
  return ctx.db.order.findMany({
    where: { userId: ctx.params.id },
  });
}

// 4. Orchestrate inside the handler
export default GET.handler(async (ctx) => {
  const user = await fetchUser(ctx);
  if (!user) {
    return notFound({ message: "User not found" });
  }

  const orders = await fetchUserOrders(ctx);
  return ok({ user, orders });
});

Real-World Example: Refactoring a Checkout Flow

Consider an e-commerce checkout route that handles inventory verification, payment processing, database record creation, and email notifications.

❌ Before: Monolithic Handler

src/routes/checkout.post.ts
// A 100+ line handler that is difficult to test and maintain
export default t
  .post("/checkout")
  .body(
    z.object({
      itemId: z.string(),
      quantity: z.number().min(1),
      paymentMethodId: z.string(),
    }),
  )
  .handler(async (ctx) => {
    // 1. Check inventory inline...
    const item = await ctx.db.item.findUnique({ where: { id: ctx.body.itemId } });
    if (!item || item.stock < ctx.body.quantity) {
      return badRequest({ message: "Out of stock" });
    }

    // 2. Charge Stripe inline...
    const charge = await ctx.stripe.charges.create({
      amount: item.price * ctx.body.quantity,
      currency: "usd",
      source: ctx.body.paymentMethodId,
    });

    // 3. Save order to database inline...
    const order = await ctx.db.order.create({
      data: {
        itemId: item.id,
        quantity: ctx.body.quantity,
        total: item.price * ctx.body.quantity,
        chargeId: charge.id,
        userId: ctx.state.user.id,
      },
    });

    // 4. Send email notification inline...
    await ctx.email.sendReceipt({ to: ctx.state.user.email, orderId: order.id });

    return ok(order);
  });

✅ After: Clean Decomposition with RouteContext

By extracting CheckoutContext, each step becomes a pure or focused async helper across modular service files:

src/routes/checkout.post.ts
import { badRequest, ok } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";
import { verifyStock } from "../services/inventory.js";
import { processPayment } from "../services/payment.js";
import { persistOrder } from "../services/order.js";

// 1. Declare Route Contract
const POST = t
  .post("/checkout")
  .body(
    z.object({
      itemId: z.string(),
      quantity: z.number().min(1),
      paymentMethodId: z.string(),
    }),
  )
  .returns({
    200: z.object({ orderId: z.string(), status: z.literal("confirmed") }),
    400: z.object({ error: z.string() }),
  });

// 2. Export Inferred Route Context for Helpers
export type CheckoutContext = typeof POST.$Infer.Context;

// 3. Clean Handler Orchestration
export default POST.handler(async (ctx) => {
  try {
    const item = await verifyStock(ctx);
    const charge = await processPayment(ctx, item);
    const order = await persistOrder(ctx, item, charge.id);

    // Background notification
    ctx.email.sendReceipt({ to: ctx.state.user.email, orderId: order.id }).catch(console.error);

    return ok({ orderId: order.id, status: "confirmed" });
  } catch (err) {
    return badRequest({ error: (err as Error).message });
  }
});

Granular Context Slicing with Pick

When writing generic service functions or utilities that should not depend on the entire route context, you can use TypeScript's standard Pick utility:

src/services/billing.ts
import type { CheckoutContext } from "../routes/checkout.post.js";

// Helper only requires db, stripe, and user state
type BillingContext = Pick<CheckoutContext, "db" | "stripe" | "state">;

export async function processCustomerCharge(
  ctx: BillingContext,
  amountInCents: number,
  paymentMethodId: string,
) {
  ctx.logger.info(`Charging customer ${ctx.state.user.id}`);
  return ctx.stripe.charges.create({
    amount: amountInCents,
    currency: "usd",
    source: paymentMethodId,
    customer: ctx.state.user.stripeCustomerId,
  });
}

This pattern enables easy unit testing: you can invoke processCustomerCharge with a lightweight mock object containing only db, stripe, and state.


Moving Helpers to Separate Service Files

For larger projects, you can organize helpers into dedicated service files:

src/routes/users/$id.get.ts
import { notFound, ok } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";
import { fetchUserProfile } from "../../services/user-service.js";

const GET = t
  .get("/users/:id")
  .params(z.object({ id: z.string().uuid() }));

export type UserRouteContext = typeof GET.$Infer.Context;

export default GET.handler(async (ctx) => {
  const profile = await fetchUserProfile(ctx);
  if (!profile) {
    return notFound({ message: "User not found" });
  }
  return ok(profile);
});

Why Infer from GET/POST Before export default?

Taser's export default architecture intentionally separates route contract declaration from handler execution, eliminating circular type hazards by design:

  1. Two-Phase Definition:

    // Phase 1: Declare route contract (query, params, body, middlewares, returns)
    const GET = t.get("/users/:id").params(UserParamsSchema);
    
    // Phase 2: Inferred cleanly from the contract BEFORE execution logic runs
    export type RouteContext = typeof GET.$Infer.Context;
    
    async function fetchUser(ctx: RouteContext) {
      return ctx.db.findUser(ctx.params.id);
    }
    
    // Phase 3: Export the handler implementation
    export default GET.handler(async (ctx) => {
      const user = await fetchUser(ctx);
      return json(user);
    });
  2. No Post-Handler Type Hazard: Because the handler is exported as export default GET.handler(...), there is no monolithic post-execution variable holding both the contract and the handler implementation at the same time. typeof default is invalid TypeScript syntax, ensuring types are always derived from the upstream builder contract (typeof GET.$Infer.Context).

[Route Builder: const GET = t.get(...)] ──► typeof GET.$Infer.Context ──► [Helper Functions]
                  │                                                               │
                  ▼                                                               ▼
        [GET.handler(async (ctx) => ...)] ◄────────────────────────────── [Orchestrate Helpers]


          [export default]

Summary of Benefits

FeatureMonolithic HandlersTaser $Infer.Context Helpers
Readability100+ line functions mixing concerns5 to 15 line orchestration handlers
Type SafetyImplicit types inside single scopeExplicitly typed ctx across all functions
TestabilityHard to isolate sub-operationsEvery helper is an independently testable unit
BoilerplateManually handwritten param interfacesZero handwritten interfaces, 100% inferred