AponiaJSDocs
Essentials

Validation

Reject invalid requests with Standard Schema or platform-native validators before the handler runs.

A route decorator accepts an optional schema. When a slot declares a validator, an invalid request answers 422 and the handler never runs.

import { Body, Controller, Post } from "@aponiajs/common";
import { z } from "zod";

const createUser = { body: z.object({ name: z.string().min(2) }) };
type CreateUser = z.infer<(typeof createUser)["body"]>;

@Controller("users")
export class UserController {
  @Post("/", createUser)
  create(@Body() body: CreateUser) {
    return body;
  }
}

The schema may be passed alone when the route has no path suffix:

@Post(createUser)
create(@Body() body: CreateUser) {
  return body;
}

Validation models

A slot also accepts a class decorated with @Validation(). The class names one validator, and a same-named interface derives its fields from that validator, so a field is declared once and controller methods use the class directly:

src/user.model.ts
import { Validation, type InferValidatorOutput } from "@aponiajs/common";
import { z } from "zod";

const createUserSchema = z.object({ name: z.string().min(2) });

@Validation(createUserSchema)
export class CreateUser {}
export interface CreateUser extends InferValidatorOutput<typeof createUserSchema> {}
src/user.controller.ts
@Controller("users")
export class UserController {
  @Post("/", { body: CreateUser })
  create(@Body() body: CreateUser) {
    return body;
  }
}

@Validation() takes exactly one complete validator, so distinct contracts use distinct classes — CreateUser for the POST body, UpdateUser for the PATCH body, UserParams for the shared identifier. resolveRouteValidator(input) returns the underlying validator for a raw validator or a model class, and an undecorated class raises INVALID_VALIDATION_MODEL.

Slots

RouteSchema declares six optional slots:

interface RouteSchema {
  readonly body?: RouteValidatorInput;
  readonly query?: RouteValidatorInput;
  readonly params?: RouteValidatorInput;
  readonly headers?: RouteValidatorInput;
  readonly cookie?: RouteValidatorInput;
  readonly response?: RouteResponseSchema;
}

A RouteValidatorInput is a raw validator or a validation-model class. The runtime constant routeSchemaSlots exports the same six names, and a schema whose slots are all absent is treated as no schema at all.

Cookie schemas are handed to Elysia unchanged, so validation and context.cookie.session.value share one inferred value type.

response accepts one success validator or a status-specific map, and the map narrows Elysia's context.status helper:

const findUser = {
  response: {
    200: t.Object({ id: t.Number() }),
    404: t.Object({ code: t.Literal("USER_NOT_FOUND") }),
  },
};

@Get(":id", findUser)
find(context: ElysiaRouteContext<typeof findUser>) {
  return context.status(404, { code: "USER_NOT_FOUND" });
}

Accepted validators

A RouteValidator is either a Standard Schema implementation or a platform-native JSON Schema validator:

  • Standard Schema — Zod, ArkType, Valibot, and any other conforming library. These are detected through the ~standard property and passed to Elysia unchanged.
  • Native — TypeBox and Elysia's t builder, matched structurally through the neutral NativeSchema contract so @aponiajs/common never depends on TypeBox.

Both kinds can appear in one application, and even in one controller:

src/item.model.ts
import { t } from "elysia";
import { z } from "zod";

export const createItemSchema = {
  body: z.object({
    name: z.string().min(2),
    quantity: z.number().int().positive(),
  }),
};

export const searchItemsSchema = {
  query: t.Object({
    term: t.String({ minLength: 1 }),
    take: t.Optional(t.Numeric()),
  }),
};

export const tenantHeaderSchema = {
  headers: t.Object({ "x-tenant": t.String({ minLength: 2 }) }),
};

export type CreateItem = z.infer<(typeof createItemSchema)["body"]>;
src/item.controller.ts
import { Body, Controller, Get, Headers, Post, Query } from "@aponiajs/common";
import { createItemSchema, searchItemsSchema, tenantHeaderSchema } from "./item.model.ts";
import type { CreateItem } from "./item.model.ts";

@Controller("items")
export class ItemController {
  @Post("/", createItemSchema)
  create(@Body() body: CreateItem) {
    return body;
  }

  @Get("/", searchItemsSchema)
  search(@Query("term") term: string, @Query("take") take: number | undefined) {
    return { term, take: take ?? 10 };
  }

  @Get("tenant", tenantHeaderSchema)
  readTenant(@Headers("x-tenant") tenant: string) {
    return { tenant };
  }
}

Elysia's t.Numeric() coerces a numeric query string, which is why take arrives as a number.

Types come from your annotations

TypeScript cannot contextually type the parameters of a decorated method, so a handler's parameter types are whatever you write — the same rule as NestJS. Keep each schema in a const and derive the type from it:

type CreateItem = z.infer<(typeof createItemSchema)["body"]>;

For TypeBox and Elysia t, use Static<typeof schema> from elysia.

A @Ctx() handler is the exception: ElysiaRouteContext<typeof schema> and the platform-neutral RouteContext<typeof schema> both infer each covered slot from its validator.

import { type RouteContext } from "@aponiajs/common";

@Post("/", createUser)
create(@Ctx() context: RouteContext<typeof createUser>) {
  return context.body;
}

InferValidatorOutput<TValidator> is exported for the same inference in your own helpers, and isStandardSchema(validator) narrows a validator to its Standard Schema form.

Failure behavior

Validation runs inside Elysia before the handler, so:

  • a rejected request answers 422 and the handler is never invoked;
  • the handler needs no defensive parsing for covered slots;
  • response validation applies to what a handler returns.

Assert the status rather than the message body — see the testing recipe.

Validation failures stay native

Rejected requests are Elysia's own 422 responses. For failures an application throws deliberately, httpError and httpErrors return RFC 9457 Problem Details — see HTTP errors. Framework-owned mapping of native validation failures, and the serialization policy, remain on the roadmap under "Validation, errors, and serialization".

On this page