Validation

Validate input with Zod schemas

JustScale uses Zod for runtime validation and type inference. Zod schemas validate request bodies, query parameters, and responses while providing full TypeScript safety.

Request Body Validation

Use the body() plugin to validate and parse request bodies:

src/controllers/players.tsTypeScript
import { z } from 'zod';
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { PlayerRepository } from '../repositories/player-repository';

const CreatePlayerSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  chips: z.number().int().positive().default(1000),
  email: z.string().email().optional(),
});

export const PlayersController = createController('/players', {
  inject: { players: PlayerRepository },
  routes: (services) => ({
    create: Post('/')
      .apply(body(CreatePlayerSchema))
      .handle(async ({ body, res }) => {
        // body is typed as { name: string; chips: number; email?: string }
        const player = await services.players.save(body);
        res.json({ player });
      }),
  }),
});

What Happens

  • Request body is parsed from JSON
  • Zod validates against the schema
  • If validation fails, automatic 400 error response
  • If successful, typed body is added to context
  • TypeScript knows the exact shape of body

Query Parameter Validation

Use the query() plugin for validating URL query parameters:

src/controllers/players.tsTypeScript
import { z } from 'zod';
import { createController } from '@justscale/core';
import { Get, query } from '@justscale/http/builder';
import { PlayerRepository } from '../repositories/player-repository';

const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(10),
  search: z.string().optional(),
});

export const PlayersController = createController('/players', {
  inject: { players: PlayerRepository },
  routes: (services) => ({
    list: Get('/')
      .apply(query(PaginationSchema))
      .handle(async ({ query, res }) => {
        // query is typed as { page: number; limit: number; search?: string }
        const offset = (query.page - 1) * query.limit;

        const results = await services.players.find({
          limit: query.limit,
          offset,
          where: query.search ? { name: query.search } : undefined,
        });

        res.json({ players: results, page: query.page });
      }),
  }),
});

Note: Use z.coerce.number() to automatically convert string query params to numbers.

Type Inference

Zod schemas provide automatic type inference:

TypeScript
import { import zz } from 'zod';

const 
const PlayerSchema: z.ZodObject<{
    id: z.ZodString;
    name: z.ZodString;
    chips: z.ZodNumber;
}, z.core.$strip>
PlayerSchema
= import zz.
function object<{
    id: z.ZodString;
    name: z.ZodString;
    chips: z.ZodNumber;
}>(shape?: {
    id: z.ZodString;
    name: z.ZodString;
    chips: z.ZodNumber;
}, params?: string | {
    error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>>;
    message?: string | undefined;
}): z.ZodObject<{
    id: z.ZodString;
    name: z.ZodString;
    chips: z.ZodNumber;
}, z.core.$strip>
object
({
id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), name: z.ZodStringname: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string(), chips: z.ZodNumberchips: import zz.function number(params?: string | z.core.$ZodNumberParams): z.ZodNumbernumber(), }); // Extract the TypeScript type type
type Player = {
    id: string;
    name: string;
    chips: number;
}
Player
= import zz.
type infer<T> = T extends {
    _zod: {
        output: any;
    };
} ? T["_zod"]["output"] : unknown
export infer
infer
<typeof
const PlayerSchema: z.ZodObject<{
    id: z.ZodString;
    name: z.ZodString;
    chips: z.ZodNumber;
}, z.core.$strip>
PlayerSchema
>;

You can use these inferred types throughout your application:

src/services/player-service.tsTypeScript
import { z } from 'zod';
import { createService } from '@justscale/core';
import type { Player } from '../schemas/player';

const CreatePlayerSchema = z.object({
  name: z.string(),
  chips: z.number().default(1000),
});

type CreatePlayerInput = z.infer<typeof CreatePlayerSchema>;

// Use in service methods
export const PlayerService = createService({
  inject: {},
  factory: () => ({
    async createPlayer(input: CreatePlayerInput): Promise<Player> {
      return { id: crypto.randomUUID(), ...input };
    },
  }),
});

Advanced Validation Patterns

Custom Error Messages

src/schemas/user.tsTypeScript
import { z } from 'zod';

const UserSchema = z.object({
  email: z.string().email('Invalid email format'),
  age: z.number().min(18, 'Must be at least 18 years old'),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain an uppercase letter'),
});

type User = z.infer<typeof UserSchema>;

Conditional Validation

src/schemas/order.tsTypeScript
import { z } from 'zod';

const OrderSchema = z.object({
  type: z.enum(['pickup', 'delivery']),
  address: z.string().optional(),
}).refine(
  (data) => data.type !== 'delivery' || data.address,
  {
    message: 'Address is required for delivery orders',
    path: ['address'],
  }
);

type Order = z.infer<typeof OrderSchema>;

Nested Objects

src/schemas/game.tsTypeScript
import { z } from 'zod';

const CreateGameSchema = z.object({
  name: z.string(),
  settings: z.object({
    maxPlayers: z.number().min(2).max(10),
    buyIn: z.number().positive(),
    blinds: z.object({
      small: z.number().positive(),
      big: z.number().positive(),
    }),
  }),
});

type CreateGameInput = z.infer<typeof CreateGameSchema>;

Arrays

src/controllers/players.tsTypeScript
import { z } from 'zod';
import { createController } from '@justscale/core';
import { Post, body } from '@justscale/http/builder';
import { PlayerRepository } from '../repositories/player-repository';

const BulkCreateSchema = z.object({
  players: z.array(
    z.object({
      name: z.string(),
      chips: z.number().default(1000),
    })
  ).min(1, 'At least one player required'),
});

export const PlayersController = createController('/players', {
  inject: { players: PlayerRepository },
  routes: (services) => ({
    bulkCreate: Post('/bulk')
      .apply(body(BulkCreateSchema))
      .handle(async ({ body, res }) => {
        // body.players is an array
        const created = await services.players.saveMany(body.players);
        res.json({ players: created });
      }),
  }),
});

Error Handling

When validation fails, JustScale automatically returns a 400 error with validation details:

JSON
{
  "error": "Validation failed",
  "details": [
    {
      "path": ["name"],
      "message": "Name is required"
    },
    {
      "path": ["chips"],
      "message": "Expected number, received string"
    }
  ]
}

To customize error handling, create a custom validation middleware:

src/middleware/custom-parse-body.tsTypeScript
import { z } from 'zod';

export const customParseBody = (schema: z.ZodType) => async (ctx: any) => {
  const result = schema.safeParse(ctx.body);

  if (!result.success) {
    // Custom error format
    ctx.res.error('Invalid request data', 422);
    throw new Error('Validation failed');
  }

  return { body: result.data };
};

Best Practices

  • Define schemas near usage - Keep schemas close to controllers
  • Reuse schemas - Share validation logic between routes
  • Use defaults - Provide sensible defaults for optional fields
  • Coerce types - Use z.coerce for query parameters
  • Validate early - Fail fast with middleware validation
  • Document schemas - Use .describe() for OpenAPI generation
src/schemas/player.tsTypeScript
import { z } from 'zod';

const PlayerSchema = z.object({
  name: z.string().describe('Player display name'),
  chips: z.number().describe('Starting chip count').default(1000),
});