Files
agent/.agent/skills/tech-stack/elysiajs/references/validation.md

10 KiB

Validation Schema - SKILLS.md

What It Is

Runtime validation + type inference + OpenAPI schema from single source. TypeBox-based with Standard Schema support.

Basic Usage

import { Elysia, t } from 'elysia'

new Elysia()
  .get('/id/:id', ({ params: { id } }) => id, {
    params: t.Object({ id: t.Number({ minimum: 1 }) }),
    response: {
      200: t.Number(),
      404: t.Literal('Not Found')
    }
  })

Schema Types

Third parameter of HTTP method:

  • body - HTTP message
  • query - URL query params
  • params - Path params
  • headers - Request headers
  • cookie - Request cookies
  • response - Response (per status)

Standard Schema Support

Use Zod, Valibot, ArkType, Effect, Yup, Joi:

import { z } from 'zod'
import * as v from 'valibot'

.get('/', ({ params, query }) => params.id, {
  params: z.object({ id: z.coerce.number() }),
  query: v.object({ name: v.literal('Lilith') })
})

Mix validators in same handler.

Body

body: t.Object({ name: t.String() })

GET/HEAD: body-parser disabled by default (RFC2616).

File Upload

body: t.Object({
  file: t.File({ format: 'image/*' }),
  multipleFiles: t.Files()
})
// Auto-assumes multipart/form-data

File (Standard Schema)

import { fileType } from 'elysia'

body: z.object({
  file: z.file().refine((file) => fileType(file, 'image/jpeg'))
})

Use fileType for security (validates magic number, not just MIME).

Query

query: t.Object({ name: t.String() })
// /?name=Elysia

Auto-coerces to specified type.

Arrays

query: t.Object({ name: t.Array(t.String()) })

Formats supported:

  • nuqs: ?name=a,b,c (comma delimiter)
  • HTML form: ?name=a&name=b&name=c (multiple keys)

Params

params: t.Object({ id: t.Number() })
// /id/1

Auto-inferred as string if schema not provided.

Headers

headers: t.Object({ authorization: t.String() })

additionalProperties: true by default. Always lowercase keys.

cookie: t.Cookie({
  name: t.String()
}, {
  secure: true,
  httpOnly: true
})

Or use t.Object. additionalProperties: true by default.

Response

response: t.Object({ name: t.String() })

Per Status

response: {
  200: t.Object({ name: t.String() }),
  400: t.Object({ error: t.String() })
}

Error Handling

Inline Error Property

body: t.Object({
  x: t.Number({ error: 'x must be number' })
})

Or function:

x: t.Number({
  error({ errors, type, validation, value }) {
    return 'Expected x to be number'
  }
})

onError Hook

.onError(({ code, error }) => {
  if (code === 'VALIDATION')
    return error.message // or error.all[0].message
})

error.all - list all error causes. error.all.find(x => x.path === '/name') - find specific field.

Reference Models

Name + reuse models:

.model({
  sign: t.Object({
    username: t.String(),
    password: t.String()
  })
})
.post('/sign-in', ({ body }) => body, {
  body: 'sign',
  response: 'sign'
})

Extract to plugin:

// auth.model.ts
export const authModel = new Elysia().model({ sign: t.Object({...}) })

// main.ts
new Elysia().use(authModel).post('/', ..., { body: 'sign' })

Naming Convention

Prevent duplicates with namespaces:

.model({
  'auth.admin': t.Object({...}),
  'auth.user': t.Object({...})
})

Or use prefix / suffix to rename models in current instance

.model({ sign: t.Object({...}) })
.prefix('model', 'auth')
.post('/', () => '', {
	body: 'auth.User'
})

Models with prefix will be capitalized.

TypeScript Types

const MyType = t.Object({ hello: t.Literal('Elysia') })
type MyType = typeof MyType.static

Single schema → runtime validation + coercion + TypeScript type + OpenAPI.

Guard

Apply schema to multiple handlers. Affects all handlers after definition.

Basic Usage

import { Elysia, t } from 'elysia'

new Elysia()
  .get('/none', ({ query }) => 'hi')
  .guard({
    query: t.Object({
      name: t.String()
    })
  })
  .get('/query', ({ query }) => query)
  .listen(3000)

Ensures query.name string required for all handlers after guard.

Behavior

Path Response
/none hi
/none?name=a hi
/query error
/query?name=a a

Precedence

  • Multiple global schemas: latest wins
  • Global vs local: local wins

Schema Types

  1. override (default) Latest schema overrides collided schema.
.guard({ query: t.Object({ name: t.String() }) })
.guard({ query: t.Object({ id: t.Number() }) })
// Only id required, name overridden
  1. standalone Both schemas run independently. Both validated.
.guard({ query: t.Object({ name: t.String() }) }, { type: 'standalone' })
.guard({ query: t.Object({ id: t.Number() }) }, { type: 'standalone' })
// Both name AND id required

Typebox Validation (Elysia.t)

Elysia.t = TypeBox with server-side pre-configuration + HTTP-specific types

TypeBox API mirrors TypeScript syntax but provides runtime validation

Basic Types

TypeBox TypeScript Example Value
t.String() string "hello"
t.Number() number 42
t.Boolean() boolean true
t.Array(t.Number()) number[] [1, 2, 3]
t.Object({ x: t.Number() }) { x: number } { x: 10 }
t.Null() null null
t.Literal(42) 42 42

Attributes (JSON Schema 7)

// Email format
t.String({ format: 'email' })

// Number constraints
t.Number({ minimum: 10, maximum: 100 })

// Array constraints
t.Array(t.Number(), {
  minItems: 1,  // min items
  maxItems: 5   // max items
})

// Object - allow extra properties
t.Object(
  { x: t.Number() },
  { additionalProperties: true }  // default: false
)

Common Patterns

Union (Multiple Types)

t.Union([t.String(), t.Number()])
// type: string | number
// values: "Hello" or 123

Optional (Field Optional)

t.Object({
  x: t.Number(),
  y: t.Optional(t.Number())  // can be undefined
})
// type: { x: number, y?: number }
// value: { x: 123 } or { x: 123, y: 456 }

Partial (All Fields Optional)

t.Partial(t.Object({
  x: t.Number(),
  y: t.Number()
}))
// type: { x?: number, y?: number }
// value: {} or { y: 123 } or { x: 1, y: 2 }

Elysia-Specific Types

UnionEnum (One of Values)

t.UnionEnum(['rapi', 'anis', 1, true, false])

File (Single File Upload)

t.File({
  type: 'image',           // or ['image', 'video']
  minSize: '1k',           // 1024 bytes
  maxSize: '5m'            // 5242880 bytes
})

File unit suffixes:

  • m = MegaByte (1048576 bytes)
  • k = KiloByte (1024 bytes)

Files (Multiple Files)

t.Files()  // extends File + array
t.Cookie({
  name: t.String()
}, {
  secrets: 'secret-key'  // or ['key1', 'key2'] for rotation
})

Nullable (Allow null)

t.Nullable(t.String())
// type: string | null

MaybeEmpty (Allow null + undefined)

t.MaybeEmpty(t.String())
// type: string | null | undefined

Form (FormData Validation)

t.Form({
  someValue: t.File()
})
// Syntax sugar for t.Object with FormData support

UInt8Array (Buffer → Uint8Array)

t.UInt8Array()
// For binary file uploads with arrayBuffer parser

ArrayBuffer (Buffer → ArrayBuffer)

t.ArrayBuffer()
// For binary file uploads with arrayBuffer parser

ObjectString (String → Object)

t.ObjectString()
// Accepts: '{"x":1}' → parses to { x: 1 }
// Use in: query string, headers, FormData

BooleanString (String → Boolean)

t.BooleanString()
// Accepts: 'true'/'false' → parses to boolean
// Use in: query string, headers, FormData

Numeric (String/Number → Number)

t.Numeric()
// Accepts: '123' or 123 → transforms to 123
// Use in: path params, query string

Elysia Behavior Differences from TypeBox

1. Optional Behavior

In Elysia, t.Optional makes entire route parameter optional (not object field):

.get('/optional', ({ query }) => query, {
  query: t.Optional(  // makes query itself optional
    t.Object({ name: t.String() })
  )
})

Different from TypeBox: TypeBox uses Optional for object fields only

2. Number → Numeric Auto-Conversion

Route schema only (not nested objects):

.get('/:id', ({ id }) => id, {
  params: t.Object({
    id: t.Number()  // ✅ Auto-converts to t.Numeric()
  }),
  body: t.Object({
    id: t.Number()  // ❌ NOT converted (stays t.Number())
  })
})

// Outside route schema
t.Number()  // ❌ NOT converted

Why: HTTP headers/query/params always strings. Auto-conversion parses numeric strings.

3. Boolean → BooleanString Auto-Conversion

Same as Number → Numeric:

.get('/:active', ({ active }) => active, {
  params: t.Object({
    active: t.Boolean()  // ✅ Auto-converts to t.BooleanString()
  }),
  body: t.Object({
    active: t.Boolean()  // ❌ NOT converted
  })
})

Usage Pattern

import { Elysia, t } from 'elysia'

new Elysia()
  .post('/', ({ body }) => `Hello ${body}`, {
    body: t.String()  // validates body is string
  })
  .listen(3000)

Validation flow:

  1. Request arrives
  2. Schema validates against HTTP body/params/query/headers
  3. If valid → handler executes
  4. If invalid → Error Life Cycle

Notes

[Inference] Based on docs:

  • TypeBox mirrors TypeScript but adds runtime validation
  • Elysia.t extends TypeBox with HTTP-specific types
  • Auto-conversion (Number→Numeric, Boolean→BooleanString) only for route schemas
  • Use t.Optional for optional route params (different from TypeBox behavior)
  • File validation supports unit suffixes ('1k', '5m')
  • ObjectString/BooleanString for parsing strings in query/headers
  • Cookie supports key rotation with array of secrets