Validation

The validation module provides composable rule factories and a validate() function for checking input data against declarative rule sets.

Quick start

import { validate, required, string, integer, min, max, email } from '@strav/http'

const { data, errors } = validate(input, {
  name:  [required(), string(), min(3), max(100)],
  email: [required(), email()],
  age:   [integer(), min(18)],
})

if (errors) {
  return ctx.json({ errors }, 422)
}

// data is typed and contains only declared fields

Rules

Every rule is a factory function returning a Rule object:
interface Rule {
  name: string
  validate(value: unknown): string | null  // error message or null
}
All rules pass on null/undefined — use required() to enforce presence.

Note: Error messages shown in the table below are the default English fallbacks. These can be customized via i18n configuration by providing translations for validation keys like validation.required, validation.string, etc.

Available rules

Rule Description Error message
required() Value must not be null, undefined, or empty string This field is required
string() Must be a string Must be a string
integer() Must be an integer Must be an integer
number() Must be a number (not NaN) Must be a number
boolean() Must be a boolean Must be a boolean
min(n) Numbers: >= n. Strings: length >= n Must be at least :min / Must be at least :min characters
max(n) Numbers: <= n. Strings: length <= n Must be at most :max / Must be at most :max characters
email() Must be a valid email address Must be a valid email address
url() Must be a valid URL Must be a valid URL
regex(pattern: RegExp) Must match a RegExp pattern Invalid format
enumOf(enum) Must be a value of the given TypeScript enum Must be one of: :values
oneOf(values) Must be one of the given values Must be one of: :values
array() Must be an array Must be an array

Examples

import { required, string, min, max, email, enumOf, oneOf, regex } from '@strav/http'
import { UserRole } from '../enums/user'

// Username: required string between 3 and 30 characters
const usernameRules = [required(), string(), min(3), max(30)]

// Role: must be a valid UserRole enum value
const roleRules = [required(), enumOf(UserRole)]

// Status: must be one of specific string values (no enum)
const statusRules = [required(), oneOf(['active', 'inactive', 'suspended'])]

// Phone: optional, but must match a pattern if provided
const phoneRules = [string(), regex(/^\+\d{10,15}$/)]

validate()

function validate(input: unknown, rules: RuleSet): ValidationResult
  • Synchronous — no async overhead.
  • Runs rules per field, stopping at the first error for each field.
  • Strips unknown fields from data — only declared fields are returned.
  • Omits undefined values — fields not present in the input are excluded from data, making partial updates safe (only submitted fields are included).
  • Returns errors: null when all rules pass.

Return type

interface ValidationResult> {
  data: T                                   // only declared fields
  errors: Record | null   // null when valid
}

Typed results

Use a generic to get typed data:
interface CreateUserInput {
  name: string
  email: string
  role: string
}

const { data, errors } = validate(body, {
  name:  [required(), string()],
  email: [required(), email()],
  role:  [required(), oneOf(['admin', 'user'])],
})

if (!errors) {
  data.name  // typed as string
  data.email // typed as string
}

Usage in controllers

The typical pattern in a controller action:

async store(ctx: Context) {
  const body = await ctx.body()
  const { data, errors } = validate(body, UserRules.store)
  if (errors) return ctx.json({ errors }, 422)

  const user = await service.create(data)
  return ctx.json(user, 201)
}

Generated validators

Running bun strav generate:api auto-generates validators from schema field definitions. Each validator has store (with required() on required fields) and update (without required()) rule sets:
// app/validators/user_validator.ts — Generated by Strav
import { required, enumOf, string } from '@strav/http'
import { UserRole } from '../enums/user'

export const UserRules: Record = {
  store: {
    username: [required(), string()],
    role: [enumOf(UserRole)],
  },
  update: {
    username: [string()],
    role: [enumOf(UserRole)],
  },
}
Enum fields use enumOf(Enum) referencing the generated TypeScript enum, so validators stay in sync when enum values change. Fields with inline enumValues (no custom type) still use oneOf([...]). System-managed fields (id, timestamps, parent FK) are excluded from generated validators. Reference FK fields (e.g., authorPid from t.reference('user')) are included using their FK column name and validated with the referenced PK's type.

Writing custom rules

A rule is just an object with name and validate:
import type { Rule } from '@strav/http'

function slug(): Rule {
  return {
    name: 'slug',
    validate(value) {
      if (value === undefined || value === null) return null
      if (typeof value !== 'string') return 'Must be a string'
      if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) return 'Must be a valid slug'
      return null
    },
  }
}
Follow the convention: return null for undefined/null (let required() handle presence), return an error string on failure.