Responses & Errors

Response Contracts

Enforce compile-time return shape safety and runtime response validation with .returns(). Eliminate response drift between backend and frontend.

In standard backend frameworks, res.json(data) is unchecked. If a database query changes or a field name is renamed, backend responses drift silently, breaking frontend consumers in production.

Taser.js solves this by letting you define Response Contracts with .returns().


Declaring Response Contracts

Attach a .returns() map containing schemas keyed by HTTP status codes:

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

const UserProfileSchema = z.object({
  id: z.string(),
  username: z.string(),
  email: z.string().email(),
  avatarUrl: z.string().url().nullable(),
});

const ErrorSchema = z.object({
  message: z.string(),
});

export default t
  .get("/profile")
  .returns({
    200: UserProfileSchema, 
    401: ErrorSchema, 
    404: ErrorSchema, 
  }) 
  .handler(async (ctx) => {
    const user = await ctx.db.getCurrentUser(ctx.state.userId);

    if (!user) {
      // Validated against 404 schema:
      return notFound({ message: "Profile not found" });
    }

    // Validated against 200 schema:
    return json({
      id: user.id,
      username: user.username,
      email: user.email,
      avatarUrl: user.avatar,
    });
  });

Compile-Time Type Checking

The TypeScript compiler checks the value passed into json() against the schema corresponding to that status code.

If you return an incorrect property name or omit a required field, TypeScript generates an immediate build error:

// ❌ TypeScript Error: Property 'email' is missing in type '{ id: string; username: string }'
return json({
  id: user.id,
  username: user.username,
});

This ensures you can refactor database models and backend services with total confidence that response shapes remain compliant.


Runtime Response Validation

In addition to compile-time verification, Taser.js can validate outgoing response payloads at runtime.

You can configure response validation behavior in src/taser.ts:

src/taser.ts
import { createTaserApp } from "@taserjs/router";

export default createTaserApp({
  response: {
    // Enable/disable runtime response validation (default: true)
    validate: process.env.NODE_ENV !== "production",

    // Custom hook when outgoing response does not match .returns() schema
    onValidationFailure: ({ status, issues, data }) => {
      console.error(
        `[Response Validation Error] Status ${status} payload did not match contract:`,
        issues,
      );
    },
  },
});

Client SDK Success Response Typing

@taserjs/router-client types await res.json() automatically from your route handlers — you do not need .returns() for client type safety. By default, the client unions successful ReplyOf payload types (200226) from reply helpers like json(), ok(), and created().

Use .returns() when you want compile-time server-side contract enforcement, runtime response validation in dev/staging, or to override client inference with an explicit 200 schema type.

Server setupClient await res.json() type
Handler only (no .returns())Auto-inferred from handler ReplyOf success returns
.returns({ 200: Schema })Schema output type for 200 (takes precedence)

See the Typed Client Guide for path conventions (_id, param) and per-request options.

Status Handling with Typed Success Payloads

Call parameterized endpoints using property chains like _id and the param argument:

src/components/user-profile.tsx
import { api } from "@/lib/api";

export async function loadUserProfile(userId: string) {
  const res = await api.users._id.$get({
    param: { id: userId },
  });

  if (res.status === 200) {
    // res.json() typed from handler or returns[200] schema
    const profile = await res.json();
    console.log("Welcome back,", profile.username);
    return profile;
  }

  if (res.status === 404) {
    console.error("User missing");
    return null;
  }

  if (res.status === 401) {
    window.location.href = "/login";
    return null;
  }

  return null;
}

Explicit Status Branching

You can branch on standard HTTP status codes or res.ok:

src/lib/fetcher.ts
import { api } from "@/lib/api";

export async function fetchProfile(userId: string) {
  const res = await api.users._id.$get({
    param: { id: userId },
  });

  if (!res.ok) {
    throw new Error(`Request failed with status ${res.status}`);
  }

  // Typed success payload (handler inference or returns[200])
  const data = await res.json();
  return data;
}

TanStack Query & React Integration

Typed contracts pair seamlessly with data fetching libraries like @tanstack/react-query:

src/hooks/use-profile.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function useProfile(userId: string) {
  return useQuery({
    queryKey: ["profile", userId],
    queryFn: async () => {
      const res = await api.users._id.$get({
        param: { id: userId },
      });

      if (res.status === 200) {
        return await res.json();
      }

      if (res.status === 404) {
        throw new Error("Profile not found");
      }

      throw new Error(`Failed to fetch profile: status ${res.status}`);
    },
  });
}