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:
- Declare the Route Builder Variable First (
GET,POST,PUT, etc.). - Extract
RouteContextusingtypeof VERB.$Infer.Context. - Write Small Helper Functions that receive
ctx: RouteContext(or a slice of it). - Call
.handler()on your route builder and orchestrate the helpers.
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
// 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:
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:
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:
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:
-
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); }); -
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 defaultis 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
| Feature | Monolithic Handlers | Taser $Infer.Context Helpers |
|---|---|---|
| Readability | 100+ line functions mixing concerns | 5 to 15 line orchestration handlers |
| Type Safety | Implicit types inside single scope | Explicitly typed ctx across all functions |
| Testability | Hard to isolate sub-operations | Every helper is an independently testable unit |
| Boilerplate | Manually handwritten param interfaces | Zero handwritten interfaces, 100% inferred |
Context and State
Learn how to manage application singletons and request-scoped state with createContext. Understand boot context, request context, and native runtime interop.
Standard Schema Validation
Validate query parameters, path params, request bodies, and headers using Zod, ArkType, Valibot, or any Standard Schema library.