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
bodyis 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.coercefor 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),
});