Skip to content

Validation

Request data is validated automatically based on your schema definitions.

How It Works

typescript
router.post('/users', {
  body: { name: 'string.min(1)', email: 'string.email' }
}, (req, res) => {
  // req.body is ALREADY validated when this runs
  res.json({ id: '1', ...req.body });
});

If validation fails, the handler is NOT called. A 400 error is passed to Express next().

Three Ways to Define Schemas

typescript
import { createSchema, String, Email, Optional, Number } from 'routik';

router.post('/users', {
  body: createSchema({
    name: String(1, 100),
    email: Email(),
    age: Optional(Number(0, 150))
  })
}, handler);

2. Shorthand Strings

typescript
router.post('/users', {
  body: {
    name: 'string.min(1)',
    email: 'string.email',
    age: 'number?'
  }
}, handler);

3. Raw Zod

typescript
import { z } from 'zod';

router.post('/users', {
  body: z.object({
    name: z.string().min(1),
    email: z.string().email()
  })
}, handler);

Validation Targets

typescript
router.get('/users/:id', {
  params: createSchema({ id: String() }),    // req.params
  query: createSchema({                       // req.query
    page: Optional(String()),
    limit: Optional(String())
  }),
  meta: { summary: 'Get user' }
}, handler);

All three targets are validated independently. If any fails, the handler is skipped.

Error Response

Invalid requests return a 400 error:

json
{
  "error": "Validation failed",
  "errors": [
    { "path": "email", "message": "Invalid email", "code": "invalid_string" },
    { "path": "name", "message": "String must contain at least 1 character(s)", "code": "too_small" }
  ]
}

Error Handler

Add middleware after your routes to handle validation errors:

typescript
app.use(router.getRouter());

app.use((err, req, res, next) => {
  if (err.status === 400) {
    return res.status(400).json({
      error: err.message,
      errors: err.errors
    });
  }
  next(err);
});

Custom Error Classes

For business logic errors, use custom error classes:

typescript
class AppError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
    this.name = 'AppError';
  }
}

class NotFoundError extends AppError {
  constructor(entity: string) {
    super(404, `${entity} not found`);
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'Authentication required') {
    super(401, message);
  }
}

Throw them in handlers:

typescript
router.get('/users/:id', {
  params: createSchema({ id: String() }),
  meta: { summary: 'Get user' }
}, (req, res) => {
  const user = userStore.find(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
});

Catch with a global error handler:

typescript
app.use((err, req, res, next) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({ error: err.message });
  }
  if (err.status === 400) {
    return res.status(400).json({ error: err.message, errors: err.errors });
  }
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

Accessing Validated Data

The validated/parsed data is available separately:

typescript
router.post('/users', {
  body: createSchema({ name: String(), age: Optional(Number()) })
}, (req, res) => {
  req.body             // Original body (may have extra fields)
  req.validatedBody    // Parsed body (types coerced, defaults applied)
  req.validatedQuery   // Parsed query params
  req.validatedParams  // Parsed path params
});

Zod strips unknown fields by default, and applies type coercion (e.g., string "123" → number 123).

Released under the MIT License.