GuidesValidation

Usage

Validate request data inputs such as req.body, req.query, and req.params declaratively on any route — no middleware boilerplate, no manual error handling. Drop a Zod schema or class-validator DTO into the validation config, and Arkos handles the rest: error responses and automatic OpenAPI spec generation.

This validation system works the same way across both ArkosRouter and RouteHook.

Request Body Validation

Applied on routes that receive a request body — typically POST, PUT, and PATCH.

src/modules/post/post.router.ts
import { ArkosRouter } from "arkos";
import z from "zod";
import postController from "./post.controller";

const router = ArkosRouter();

const CreatePostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  published: z.boolean().optional(),
  authorId: z.string().uuid(),
});

router.post(
  {
    path: "/api/posts",
    validation: {
      body: CreatePostSchema,
    },
  },
  postController.createPost
);

export default router;
src/modules/post/post.router.ts
import { ArkosRouter } from "arkos";
import { IsString, IsBoolean, IsUUID, IsOptional, MinLength } from "class-validator";
import postController from "./post.controller";

const router = ArkosRouter();

class CreatePostDto {
  @IsString()
  @MinLength(1)
  title: string;

  @IsString()
  @MinLength(1)
  content: string;

  @IsBoolean()
  @IsOptional()
  published?: boolean;

  @IsUUID()
  authorId: string;
}

router.post(
  {
    path: "/api/posts",
    validation: {
      body: CreatePostDto,
    },
  },
  postController.createPost
);

export default router;
src/modules/post/post.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import z from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  published: z.boolean().optional(),
  authorId: z.string().uuid(),
});

export const hook: RouteHook = {
  createOne: {
    validation: {
      body: CreatePostSchema,
    },
  },
};

const router = ArkosRouter();

export default router;
src/modules/post/post.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import { IsString, IsBoolean, IsUUID, IsOptional, MinLength } from "class-validator";

class CreatePostDto {
  @IsString()
  @MinLength(1)
  title: string;

  @IsString()
  @MinLength(1)
  content: string;

  @IsBoolean()
  @IsOptional()
  published?: boolean;

  @IsUUID()
  authorId: string;
}

export const hook: RouteHook = {
  createOne: {
    validation: {
      body: CreatePostDto,
    },
  },
};

const router = ArkosRouter();

export default router;

RouteHook is the new name for export const config: RouterConfig. If you have existing code using the old name it still works but will log a deprecation warning. See Route Hook for full details.

Validation error response:

{
  "status": "error",
  "message": "Invalid Data",
  "code": 400,
  "errors": [
    {
      "property": "authorId",
      "constraints": {
        "isUuid": "authorId must be a valid UUID"
      }
    }
  ]
}

Request Query & Params Validation

Applied on routes that receive URL query strings or path parameters.

Query and params values arrive as strings from the URL. Use z.coerce or @Type() to cast them to the correct type — or use the CLI code generation which handles this automatically.

src/modules/user/user.router.ts
import { ArkosRouter } from "arkos";
import z from "zod";
import userController from "./user.controller";

const router = ArkosRouter();

router.get(
  {
    path: "/api/users",
    validation: {
      query: z.object({
        role: z.enum(["admin", "user"]).optional(),
        active: z.coerce.boolean().optional(),
        limit: z.coerce.number().int().min(1).max(100).optional(),
      }),
    },
  },
  userController.getUsers
);

router.get(
  {
    path: "/api/users/:id",
    validation: {
      params: z.object({
        id: z.string().uuid("Invalid user ID"),
      }),
    },
  },
  userController.getUser
);

router.patch(
  {
    path: "/api/users/:id",
    validation: {
      params: z.object({
        id: z.string().uuid(),
      }),
      body: z.object({
        name: z.string().min(1).optional(),
        email: z.string().email().optional(),
      }),
      query: z.object({
        notify: z.coerce.boolean().optional(),
      }),
    },
  },
  userController.updateUser
);

export default router;
src/modules/user/user.router.ts
import { ArkosRouter } from "arkos";
import { IsEnum, IsBoolean, IsInt, IsUUID, IsString, IsEmail, IsOptional, Min, Max } from "class-validator";
import { Type } from "class-transformer";
import userController from "./user.controller";

const router = ArkosRouter();

class UserQueryDto {
  @IsEnum(["admin", "user"])
  @IsOptional()
  role?: string;

  @Type(() => Boolean)
  @IsBoolean()
  @IsOptional()
  active?: boolean;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  @IsOptional()
  limit?: number;
}

class UserParamsDto {
  @IsUUID()
  id: string;
}

class UpdateUserBodyDto {
  @IsString()
  @IsOptional()
  name?: string;

  @IsEmail()
  @IsOptional()
  email?: string;
}

class UpdateUserQueryDto {
  @Type(() => Boolean)
  @IsBoolean()
  @IsOptional()
  notify?: boolean;
}

router.get(
  {
    path: "/api/users",
    validation: { query: UserQueryDto },
  },
  userController.getUsers
);

router.get(
  {
    path: "/api/users/:id",
    validation: { params: UserParamsDto },
  },
  userController.getUser
);

router.patch(
  {
    path: "/api/users/:id",
    validation: {
      params: UserParamsDto,
      body: UpdateUserBodyDto,
      query: UpdateUserQueryDto,
    },
  },
  userController.updateUser
);

export default router;
src/modules/post/post.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import z from "zod";

export const hook: RouteHook = {
  findMany: {
    validation: {
      query: z.object({
        published: z.coerce.boolean().optional(),
        limit: z.coerce.number().int().min(1).max(100).optional(),
      }),
    },
  },
  updateOne: {
    validation: {
      body: z.object({
        title: z.string().min(1).optional(),
        content: z.string().min(1).optional(),
      }),
      query: z.object({
        notify: z.coerce.boolean().optional(),
      }),
    },
  },
};

const router = ArkosRouter();

export default router;
src/modules/post/post.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import { IsBoolean, IsInt, IsString, IsOptional, Min, Max, MinLength } from "class-validator";
import { Type } from "class-transformer";

class PostQueryDto {
  @Type(() => Boolean)
  @IsBoolean()
  @IsOptional()
  published?: boolean;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  @IsOptional()
  limit?: number;
}

class UpdatePostBodyDto {
  @IsString()
  @MinLength(1)
  @IsOptional()
  title?: string;

  @IsString()
  @MinLength(1)
  @IsOptional()
  content?: string;
}

class UpdatePostQueryDto {
  @Type(() => Boolean)
  @IsBoolean()
  @IsOptional()
  notify?: boolean;
}

export const hook: RouteHook = {
  findMany: {
    validation: {
      query: PostQueryDto,
    },
  },
  updateOne: {
    validation: {
      body: UpdatePostBodyDto,
      query: UpdatePostQueryDto,
    },
  },
};

const router = ArkosRouter();

export default router;

RouteHook is the new name for export const config: RouterConfig. If you have existing code using the old name it still works but will log a deprecation warning. See Route Hook for full details.

Validation error response:

{
  "status": "error",
  "message": "Invalid Data",
  "code": 400,
  "errors": [
    {
      "property": "id",
      "constraints": {
        "isUuid": "id must be a valid UUID"
      }
    }
  ]
}

Validation With File Uploads

When combining validation with file uploads, only pass text fields to validation.body — file fields are handled separately by the uploads config.

router.post(
  {
    path: "/api/users/:id/avatar",
    validation: {
      params: z.object({ id: z.string().uuid() }),
      body: z.object({ caption: z.string().optional() }),
      // no avatar field here — handled by uploads
    },
    experimental: {
      uploads: { type: "single", field: "avatar", required: true },
    },
  },
  userController.uploadAvatar
);
import { IsUUID, IsString, IsOptional } from "class-validator";

class UploadAvatarParamsDto {
  @IsUUID()
  id: string;
}

class UploadAvatarBodyDto {
  @IsString()
  @IsOptional()
  caption?: string;
}

router.post(
  {
    path: "/api/users/:id/avatar",
    validation: {
      params: UploadAvatarParamsDto,
      body: UploadAvatarBodyDto,
      // no avatar field here — handled by uploads
    },
    experimental: {
      uploads: { type: "single", field: "avatar", required: true },
    },
  },
  userController.uploadAvatar
);
src/modules/file/file.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import z from "zod";

export const hook: RouteHook = {
  uploadFile: {
    validation: {
      body: z.object({ caption: z.string().optional() }),
      // no file field here — handled by uploads
    },
  },
};

const router = ArkosRouter();

export default router;
src/modules/file/file.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import { IsString, IsOptional } from "class-validator";

class UploadFileBodyDto {
  @IsString()
  @IsOptional()
  caption?: string;
}

export const hook: RouteHook = {
  uploadFile: {
    validation: {
      body: UploadFileBodyDto,
      // no file field here — handled by uploads
    },
  },
};

const router = ArkosRouter();

export default router;

RouteHook is the new name for export const config: RouterConfig. If you have existing code using the old name it still works but will log a deprecation warning. See Route Hook for full details.

requiredBehavior
trueReturns 400 if no file is uploaded
falseProceeds without a file

See File Upload guide for full configuration.

Accessing Validated Data

Arkos automatically types req.body, req.query, and req.params from your validation schema — every handler and middleware in the route stack shares the same typed req with no manual generic declarations needed.

src/modules/user/user.router.ts
import { ArkosRouter } from "arkos";
import z from "zod";
import userController from "@/src/modules/user/user.controller";

const router = ArkosRouter();

const UpdateUserBody = z.object({
  name: z.string().min(1).optional(),
  email: z.string().email().optional(),
});

const UpdateUserParams = z.object({
  id: z.string().uuid(),
});

const UpdateUserQuery = z.object({
  notify: z.coerce.boolean().optional(),
});

router.patch(
  {
    path: "/api/users/:id",
    validation: {
      params: UpdateUserParams,
      body: UpdateUserBody,
      query: UpdateUserQuery,
    },
  },
  logMiddleware,   // req.params, req.body, req.query all typed here
  userController.updateOne // and here — same signature, no extra work
);

export default router;
src/modules/user/user.router.ts
import { ArkosRouter } from "arkos";
import { IsString, IsEmail, IsBoolean, IsUUID, IsOptional } from "class-validator";
import { Type } from "class-transformer";
import userController from "@/src/modules/user/user.controller";

const router = ArkosRouter();

class UpdateUserBodyDto {
  @IsString()
  @IsOptional()
  name?: string;

  @IsEmail()
  @IsOptional()
  email?: string;
}

class UpdateUserParamsDto {
  @IsUUID()
  id: string;
}

class UpdateUserQueryDto {
  @Type(() => Boolean)
  @IsBoolean()
  @IsOptional()
  notify?: boolean;
}

router.patch(
  {
    path: "/api/users/:id",
    validation: {
      params: UpdateUserParamsDto,
      body: UpdateUserBodyDto,
      query: UpdateUserQueryDto,
    },
  },
  logMiddleware,
  userController.updateOne
);

export default router;
src/modules/auth/auth.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import z from "zod";

const UpdateMeBody = z.object({
  name: z.string().min(1).optional(),
  email: z.string().email().optional(),
});

export const hook: RouteHook = {
  updateMe: {
    validation: {
      body: UpdateMeBody,
    },
  },
};

const router = ArkosRouter();

export default router;
src/modules/auth/auth.middleware.ts
import { ArkosRequest, ArkosResponse, NextFunction } from "arkos";
import { z } from "zod";

const UpdateMeBody = z.object({
  name: z.string().min(1).optional(),
  email: z.string().email().optional(),
});

type UpdateMeBody = z.infer<typeof UpdateMeBody>;

export const beforeUpdateMe = (
  req: ArkosRequest<any, any, UpdateMeBody>,
  res: ArkosResponse,
  next: NextFunction
) => {
  const { name, email } = req.body; // typed, validated
  next();
};
src/modules/auth/auth.router.ts
import { ArkosRouter, RouteHook } from "arkos";
import { IsString, IsEmail, IsOptional } from "class-validator";

class UpdateMeBodyDto {
  @IsString()
  @IsOptional()
  name?: string;

  @IsEmail()
  @IsOptional()
  email?: string;
}

export const hook: RouteHook = {
  updateMe: {
    validation: {
      body: UpdateMeBodyDto,
    },
  },
};

const router = ArkosRouter();

export default router;
src/modules/auth/auth.middleware.ts
import { ArkosRequest, ArkosResponse, NextFunction } from "arkos";

export const beforeUpdateMe = (
  req: ArkosRequest<any, any, UpdateMeBodyDto>,
  res: ArkosResponse,
  next: NextFunction
) => {
  const { name, email } = req.body; // typed, validated
  next();
};

RouteHook is the new name for export const config: RouterConfig. If you have existing code using the old name it still works but will log a deprecation warning. See Route Hook for full details.

src/modules/user/user.controller.ts
import { ArkosRequest, ArkosResponse } from "arkos";

const updateOne = async (req: ArkosRequest, res: ArkosResponse) => {
  const { id } = req.params;        // string, validated UUID
  const { notify } = req.query;     // boolean, coerced
  const { name, email } = req.body; // typed, validated
};

export default { updateOne };