Query Parameters & Request Body

Parse and validate query strings and request bodies with Zod schemas

JustScale provides query() and body() plugins for validating query parameters and request bodies using Zod schemas.

Query Parameters

Accessing Raw Query Parameters

Query parameters are available as strings in the query object:

src/controllers/search.tsTypeScript
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';

export const SearchController = createController('/search', {
  routes: () => ({
    search: Get('/').handle(({ query, res }) => {
      // Request: /search?q=hello&limit=10
      console.log(query.q);      // "hello"
      console.log(query.limit);  // "10" (string!)
      res.json({ query });
    }),
  }),
});
⚠️

Warning

Query parameters are always strings. Use query() for type conversion and validation.

Using query() Plugin

The query() plugin validates and transforms query parameters using a Zod schema:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Get, query } from '@justscale/http/builder';
import { PaginationSchema } from '../schemas/pagination';

export const UsersController = createController('/users', {
  routes: () => ({
    list: Get('/')
      .apply(query(PaginationSchema))
      .handle(({ query, res }) => {
        // query is now typed as { page: number; limit: number }
        const { page, limit } = query;
        res.json({ page, limit });
      }),
  }),
});

Example requests:

Bash
# With query params
curl "http://localhost:3000/users?page=2&limit=20"
# { "page": 2, "limit": 20 }

# Using defaults
curl "http://localhost:3000/users"
# { "page": 1, "limit": 10 }

# Invalid values
curl "http://localhost:3000/users?page=-1"
# { "error": "Validation error message" } - 400 Bad Request

Query Validation Patterns

Search & Filtering

src/controllers/products.tsTypeScript
import { createController } from '@justscale/core';
import { Get, query } from '@justscale/http/builder';
import { SearchSchema } from '../schemas/search';

export const ProductsController = createController('/products', {
  routes: () => ({
    list: Get('/')
      .apply(query(SearchSchema))
      .handle(({ query, res }) => {
        // query.q: string | undefined
        // query.status: 'active' | 'inactive' | 'all'
        // query.sort: 'name' | 'created' | 'updated'
        res.json({ query });
      }),
  }),
});

Boolean Flags

src/controllers/items.tsTypeScript
import { createController } from '@justscale/core';
import { Get, query } from '@justscale/http/builder';
import { FlagsSchema } from '../schemas/flags';

export const ItemsController = createController('/items', {
  routes: () => ({
    list: Get('/')
      .apply(query(FlagsSchema))
      .handle(({ query, res }) => {
        // ?includeDeleted=true → query.includeDeleted = true
        // ?includeDeleted=false → query.includeDeleted = false
        // (no param) → query.includeDeleted = false (default)
        res.json({ includeDeleted: query.includeDeleted });
      }),
  }),
});

Date Ranges

src/controllers/reports.tsTypeScript
import { createController } from '@justscale/core';
import { Get, query } from '@justscale/http/builder';
import { DateRangeSchema } from '../schemas/date-range';

export const ReportsController = createController('/reports', {
  routes: () => ({
    list: Get('/')
      .apply(query(DateRangeSchema))
      .handle(({ query, res }) => {
        // ?startDate=2024-01-01&endDate=2024-12-31
        // query.startDate: Date | undefined
        // query.endDate: Date | undefined
        res.json({
          start: query.startDate?.toISOString(),
          end: query.endDate?.toISOString(),
        });
      }),
  }),
});

Request Body

Accessing Raw Body

The request body is automatically parsed as JSON and available in the body property:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';

export const UsersController = createController('/users', {
  routes: () => ({
    create: Post('/').handle(({ body, res }) => {
      // body is typed as 'unknown'
      console.log(body); // { name: "Alice", email: "alice@example.com" }
      res.json({ received: body });
    }),
  }),
});
⚠️

Warning

Raw rawBody is typed as unknown. Always validate it before use!

Using body() Plugin

The body() plugin validates the request body against a Zod schema and exposes it as typed body in the context:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { UserService } from '../services/user-service';
import { CreateUserBody, UserResponse, ErrorResponse } from '../schemas/user';

export const UsersController = createController('/users', {
  inject: { users: UserService },

  routes: (services) => ({
    create: Post('/')
      .apply(body(CreateUserBody))
      .returns(UserResponse, 201)
      .returns(ErrorResponse, 400)  // Validation errors
      .handle(async ({ body, res }) => {
        // body is typed as { name: string; email: string; age?: number }
        const user = await services.users.create(body);
        res.status(201).json({ user });
      }),
  }),
});

Example requests:

Bash
# Valid request
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"alice@example.com","age":25}'
# 201 Created

# Invalid email
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","email":"invalid"}'
# { "error": "Invalid email" } - 400 Bad Request

# Missing required field
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com"}'
# { "error": "Name is required" } - 400 Bad Request

Validation Patterns

Nested Objects

src/controllers/posts.tsTypeScript
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { CreatePostSchema } from '../schemas/post';

export const PostsController = createController('/posts', {
  routes: () => ({
    create: Post('/')
      .apply(body(CreatePostSchema))
      .handle(({ body, res }) => {
        // body.author.name, body.author.email, body.tags are all typed
        res.status(201).json({ post: body });
      }),
  }),
});

Optional Fields with Defaults

src/controllers/players.tsTypeScript
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { CreatePlayerSchema } from '../schemas/player';

export const PlayersController = createController('/players', {
  routes: () => ({
    create: Post('/')
      .apply(body(CreatePlayerSchema))
      .handle(({ body, res }) => {
        // If chips is not provided, defaults to 1000
        // If isActive is not provided, defaults to true
        res.status(201).json({ player: body });
      }),
  }),
});

Union Types

src/controllers/profile.tsTypeScript
import { createController } from '@justscale/core';
import { Put, body } from '@justscale/http/builder';
import { UpdateUserSchema } from '../schemas/profile';

export const ProfileController = createController('/profile', {
  routes: () => ({
    update: Put('/')
      .apply(body(UpdateUserSchema))
      .handle(({ body, res }) => {
        // TypeScript knows body.type is either 'email' or 'password'
        if (body.type === 'email') {
          // body.email is available
        } else {
          // body.currentPassword and body.newPassword are available
        }
        res.json({ updated: true });
      }),
  }),
});

Combining Query and Body

You can use both query() and body() in the same route:

src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post, query, body } from '@justscale/http/builder';
import { UserService, sendWelcomeEmail } from '../services/user-service';
import { QuerySchema, CreateUserBody, UserResponse, ErrorResponse } from '../schemas/user';

export const UsersController = createController('/users', {
  inject: { users: UserService },

  routes: (services) => ({
    create: Post('/')
      .apply(query(QuerySchema))
      .apply(body(CreateUserBody))
      .returns(UserResponse, 201)
      .returns(ErrorResponse, 400)
      .handle(async ({ query, body, res }) => {
        // query.notify: boolean
        // body.name: string
        // body.email: string

        const user = await services.users.create(body);
        if (query.notify) {
          sendWelcomeEmail(user.email);
        }
        res.status(201).json({ user });
      }),
  }),
});

Error Handling

When validation fails, query() or body() automatically:

  • Returns a 400 Bad Request status with field-level errors
  • Stops execution before reaching the handler
ℹ️

Info

The validation error messages come directly from Zod. Customize them using Zod's error message options.
src/controllers/users.tsTypeScript
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { UserSchema } from '../schemas/user';

export const UsersController = createController('/users', {
  routes: () => ({
    create: Post('/')
      .apply(body(UserSchema))
      .handle(({ body, res }) => {
        // This handler only runs if validation succeeds
        res.status(201).json({ user: body });
      }),
  }),
});