Arcwayv0.3.0

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.

On this page