<!-- Markdown mirror of https://justscale.sh/docs/techniques/validation -->

# Validation

Validate input with Zod schemas

JustScale uses [Zod](https://zod.dev) 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

```typescript
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('/')
      .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

```typescript
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('/')
      .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

```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 z`z.`

```
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.ZodString`id: `import z`z.`function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)`string(),
`name: z.ZodString`name: `import z`z.`function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)`string(),
`chips: z.ZodNumber`chips: `import z`z.`function number(params?: string | z.core.$ZodNumberParams): z.ZodNumber`number(),
});

// Extract the TypeScript type
type

`

```
type Player = {
    id: string;
    name: string;
    chips: number;
}
```

`Player = `import z`z.`

```
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

```typescript
import { z } from 'zod';
import { defineService } 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 class PlayerService extends defineService({
  inject: {},
  factory: () => ({
    async createPlayer(input: CreatePlayerInput): Promise<Player> {
      return { id: crypto.randomUUID(), ...input };
    },
  }),
}) {}
```

## Advanced Validation Patterns

### Custom Error Messages

src/schemas/user.tsTypeScript

```typescript
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

```typescript
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

```typescript
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

```typescript
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')
      .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

```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

```typescript
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

```typescript
import { z } from 'zod';

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

## Next Steps

- OpenAPI
- Error Handling
- Testing
