Schema Validation
Schema validation with ArkType — syntax reference, common patterns, and examples
Arcway uses ArkType for schema validation. Define schemas as plain objects in your route config — the framework compiles them automatically. No imports needed.
Basic Usage
// api/users/index.js
export const POST = {
schema: {
body: {
name: 'string',
email: 'string.email',
},
},
handler: async (ctx) => {
const { name, email } = ctx.req.body; // validated and type-coerced
const [id] = await ctx.db('users').insert({ name, email });
return { status: 201, data: { id, name, email } };
},
};If validation fails, Arcway returns a 400 with structured error details:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"fieldErrors": {
"email": ["must be a valid email (was \"not-an-email\")"]
}
}
}
}Schema Locations
Schemas can validate both query parameters and request bodies:
export const GET = {
schema: {
query: {
page: 'number.integer > 0',
'limit?': 'number.integer > 0 & number <= 100',
},
},
handler: async (ctx) => {
const { page, limit = 20 } = ctx.req.query;
// ...
},
};
export const POST = {
schema: {
body: {
title: 'string >= 1',
content: 'string',
},
},
handler: async (ctx) => {
const { title, content } = ctx.req.body;
// ...
},
};Path parameters (like [id]) are merged into ctx.req.query, so you validate them in the query schema:
// api/users/[id].js
export const GET = {
schema: {
query: { id: /^\d+$/ },
},
handler: async (ctx) => {
const user = await ctx.db('users').where('id', Number(ctx.req.query.id)).first();
if (!user) return { status: 404, error: { code: 'NOT_FOUND', message: 'User not found' } };
return { data: user };
},
};ArkType Syntax Reference
ArkType uses a concise string-based syntax for type definitions. Here are the most common patterns:
Primitive Types
{
name: 'string', // any string
age: 'number', // any number
active: 'boolean', // true or false
data: 'object', // any object
tags: 'string[]', // array of strings
}String Constraints
{
name: 'string >= 1', // non-empty string (length >= 1)
bio: 'string >= 1 & string <= 500', // length between 1 and 500
email: 'string.email', // valid email format
url: 'string.url', // valid URL format
uuid: 'string.uuid', // valid UUID format
ip: 'string.ip', // valid IP address
date: 'string.date.iso', // valid ISO date string
}Number Constraints
{
age: 'number.integer > 0', // positive integer
price: 'number >= 0', // non-negative number
quantity: 'number.integer >= 1 & number <= 1000', // integer between 1 and 1000
rating: 'number >= 1 & number <= 5', // between 1 and 5
}Optional Fields
Append ? to the key name to make a field optional:
{
name: 'string', // required
'bio?': 'string', // optional
'age?': 'number', // optional
'tags?': 'string[]', // optional
}Unions (Either/Or)
{
status: "'active' | 'inactive' | 'pending'", // string literal union
role: "'admin' | 'user' | 'moderator'",
id: 'string | number', // type union
}Regex Patterns
Use regex literals for custom string validation:
{
id: /^\d+$/, // numeric string
slug: /^[a-z0-9-]+$/, // lowercase alphanumeric with dashes
hex: /^#[0-9a-fA-F]{6}$/, // hex color code
}Nested Objects
{
user: {
name: 'string',
email: 'string.email',
},
'address?': {
street: 'string',
city: 'string',
'zip?': 'string',
},
}Arrays
{
tags: 'string[]', // array of strings
scores: 'number[]', // array of numbers
items: [{ // array of objects
name: 'string',
quantity: 'number.integer > 0',
}],
}Common Patterns
User Registration
export const POST = {
schema: {
body: {
name: 'string >= 1 & string <= 100',
email: 'string.email',
password: 'string >= 8',
},
},
handler: async (ctx) => {
const { db, log } = ctx;
const { name, email, password } = ctx.req.body;
// ...
},
};Paginated List
export const GET = {
schema: {
query: {
'page?': 'number.integer > 0',
'limit?': 'number.integer > 0 & number <= 100',
'sort?': "'created_at' | 'name' | 'updated_at'",
'order?': "'asc' | 'desc'",
},
},
handler: async (ctx) => {
const { page = 1, limit = 20, sort = 'created_at', order = 'desc' } = ctx.req.query;
const offset = (page - 1) * limit;
const items = await ctx.db('items').orderBy(sort, order).limit(limit).offset(offset);
return { data: items };
},
};Search with Filters
export const GET = {
schema: {
query: {
'q?': 'string >= 1',
'status?': "'active' | 'archived' | 'draft'",
'category?': 'string',
'minPrice?': 'number >= 0',
'maxPrice?': 'number >= 0',
},
},
handler: async (ctx) => {
const { db } = ctx;
let query = db('products');
const { q, status, category, minPrice, maxPrice } = ctx.req.query;
if (q) query = query.where('name', 'like', `%${q}%`);
if (status) query = query.where('status', status);
if (category) query = query.where('category', category);
if (minPrice !== undefined) query = query.where('price', '>=', minPrice);
if (maxPrice !== undefined) query = query.where('price', '<=', maxPrice);
return { data: await query };
},
};File Upload Metadata
export const POST = {
schema: {
body: {
filename: 'string >= 1',
contentType: /^(image|application|text)\//,
size: 'number.integer > 0 & number <= 10485760', // max 10 MB
'description?': 'string <= 500',
},
},
handler: async (ctx) => {
const { filename, contentType, size } = ctx.req.body;
// ...
},
};Middleware Schema Validation
Middleware can also declare schemas. They validate before the route handler runs:
// api/_middleware.js
export default {
schema: {
query: { apiKey: 'string >= 1' },
},
handler: async (ctx) => {
const { log } = ctx;
log.info(`API key: ${ctx.req.query.apiKey}`);
},
};Error Handling
When validation fails, the response is always a 400 with this structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"fieldErrors": {
"fieldName": ["error message 1", "error message 2"]
}
}
}
}Multiple fields can fail at once — all errors are returned together so the client can display them all.
ArkType Documentation
For the full ArkType syntax reference, see the ArkType docs. Arcway supports anything ArkType supports — the schema objects are passed directly to ArkType's type() function internally.