Skip to content

@mantleframework/validation

Zod-based request and response validation for Lambda handlers. This package provides defineApiHandler -- the primary way to create API Gateway Lambda handlers in Mantle.

defineApiHandler()

Create a fully-observable API Gateway Lambda handler with optional body/query validation, configurable auth, and OpenAPI metadata.

typescript
import { defineApiHandler } from '@mantleframework/validation'

Signature:

typescript
function defineApiHandler<TBody = void, TQuery = void, TPath = void, TAuth extends 'bearer' | 'authorizer' | 'authorizer-optional' | 'session' | 'none' = 'none'>(
  options: DefineApiHandlerOptions<TBody, TQuery, TPath, TAuth>
): (handler: (params) => Promise<APIGatewayProxyResult>) => (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyResult>

Options:

OptionTypeDefaultDescription
schemaz.ZodSchema<TBody>-Request body validation schema
querySchemaz.ZodSchema<TQuery>-Query string parameter validation schema
auth'bearer' | 'authorizer' | 'authorizer-optional' | 'session' | 'none''none'Auth mode
pathSchemaz.ZodSchema<TPath>-Path parameter validation schema
responseSchemaz.ZodSchema-Response schema (stored in OpenAPI registry; not enforced at runtime)
operationNamestringhandler function nameMetrics/tracing name
loggingLoggingConfig-Logging configuration forwarded to withObservability
openapiOpenApiMetadata-OpenAPI metadata for doc generation
timeoutnumber-Lambda timeout in seconds
memorySizenumber-Lambda memory in MB
reservedConcurrencynumber-Reserved concurrent executions
ephemeralStoragenumber-Ephemeral storage in MB (/tmp)
deadLetterQueueboolean | { targetArn?: string }-Dead letter queue configuration
retryAttemptsnumber-Max retry attempts on async failure (0--2)

The params type adapts based on schema, querySchema, and auth:

ConfigurationParams type
No schema, no queryApiHandlerParams -- { event, context, metadata }
schema onlyValidatedApiParams<TBody> -- { event, context, metadata, body }
querySchema onlyValidatedQueryParams<TQuery> -- { event, context, metadata, query }
Both schemasValidatedBodyAndQueryParams<TBody, TQuery> -- { event, context, metadata, body, query }
auth: 'authorizer'AuthorizedApiParams<TBody, TQuery, TPath> -- adds authorizer, userId: string, userStatus
auth: 'authorizer-optional'AuthorizedOptionalApiParams<TBody, TQuery, TPath> -- adds authorizer, userId: string | undefined, userStatus
auth: 'session'SessionApiParams<TBody, TQuery, TPath> -- adds userId, session

Features

  • Full observability (metrics, tracing, logging) via withObservability
  • Optional Zod body and query parameter validation with structured error responses
  • Configurable auth: bearer token, Lambda authorizer, Better Auth session, or none
  • Automatic error-to-HTTP mapping via buildErrorResponse
  • Correlation ID extraction and propagation
  • OpenAPI metadata registration for spec generation

GET handler (no body)

typescript
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'

const ResponseSchema = z.object({
  items: z.array(z.object({ id: z.string(), name: z.string() })),
})

const api = defineApiHandler({ auth: 'bearer', operationName: 'ListItems' })
export const handler = api(async ({ event, context }) => {
  const items = await ItemQueries.getAll()
  return buildValidatedResponse(context, 200, { items }, ResponseSchema)
})

POST handler (with body validation)

typescript
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().int().positive().optional(),
})

const UserResponseSchema = z.object({
  id: z.string(),
  email: z.string(),
  name: z.string(),
  age: z.number().optional(),
})

const api = defineApiHandler({ schema: CreateUserSchema, auth: 'bearer', operationName: 'CreateUser' })
export const handler = api(async ({ event, context, body }) => {
  // body is typed as { email: string; name: string; age?: number }
  const user = await UserQueries.create(body)
  return buildValidatedResponse(context, 201, user, UserResponseSchema)
})

GET handler with query parameters

typescript
import { buildValidatedResponse } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'

const QuerySchema = z.object({
  page: z.coerce.number().default(1),
  limit: z.coerce.number().default(20),
})

const api = defineApiHandler({ querySchema: QuerySchema, auth: 'bearer' })
export const handler = api(async ({ context, query }) => {
  // query is typed as { page: number; limit: number }
  const items = await ItemQueries.paginated(query.page, query.limit)
  return buildValidatedResponse(context, 200, { items }, ResponseSchema)
})

Handler with OpenAPI metadata

typescript
const api = defineApiHandler({
  schema: CreateUserSchema,
  auth: 'bearer',
  openapi: {
    summary: 'Create a user',
    tags: ['users'],
    operationId: 'createUser',
  },
})

Validation error response

When body or query validation fails, defineApiHandler returns a 400 response with structured errors:

json
{
  "message": "Bad Request",
  "errors": {
    "email": ["Invalid email"],
    "name": ["Required"]
  }
}

Public handler (no auth)

typescript
const api = defineApiHandler({ operationName: 'HealthCheck' })
export const handler = api(async ({ context }) => {
  return buildValidatedResponse(context, 200, { status: 'ok' })
})

validateSchema(schema, data)

Validate data against a Zod schema. Returns a ValidationResult<T>.

Always check result.success explicitly -- the result is an object, so bare truthiness checks are always truthy.

typescript
import { validateSchema } from '@mantleframework/validation'

const result = validateSchema(MySchema, requestBody)
if (!result.success) {
  // result.errors: { email: ['Invalid email'], _form: ['...'] }
}
// result.data is the parsed and coerced payload

validateResponse(schema, data)

Validate response data against a schema. Throws a generic Error on failure (Zod details are not exposed). Use for development-time response validation.

typescript
import { validateResponse } from '@mantleframework/validation'

const validated = validateResponse(ResponseSchema, responseData)

toJsonSchema(schema, options?)

Convert a Zod schema to JSON Schema using Zod 4's native conversion. Replaces the broken zod-to-json-schema third-party library.

typescript
import { toJsonSchema } from '@mantleframework/validation'

const jsonSchema = toJsonSchema(MyZodSchema, { target: 'draft-2020-12' })

Options:

OptionTypeDescription
namestringOptional schema name
target'draft-2020-12' | 'draft-07' | 'draft-04'JSON Schema draft version

Types

ValidationResult<T>

typescript
interface ValidationResult<T = unknown> {
  success: boolean
  data?: T                          // parsed payload (present when success is true)
  errors?: Record<string, string[]> // field-keyed errors (present when success is false)
}

OpenApiMetadata

typescript
interface OpenApiMetadata {
  summary?: string
  description?: string
  tags?: string[]
  deprecated?: boolean
  operationId?: string
}

OpenApiRegistryEntry

typescript
interface OpenApiRegistryEntry {
  handlerId: string
  metadata: OpenApiMetadata
  requestSchema?: z.ZodSchema
  querySchema?: z.ZodSchema
  responseSchema?: z.ZodSchema
  auth?: 'bearer' | 'authorizer' | 'authorizer-optional' | 'session' | 'none'
}

Auth modes

ModeuserId typeBehavior on anonymous request
'authorizer'stringThrows UnauthorizedError -- handler never runs
'authorizer-optional'string | undefinedPasses through -- handler receives userStatus: 'Anonymous' and must branch
'bearer'n/aValidates static bearer token from API_BEARER_TOKEN env var
'session'stringValidates Better Auth session token -- throws on invalid/expired
'none'n/aNo auth (default)

Handler with authorizer-optional (anonymous + authenticated)

Use auth: 'authorizer-optional' when an endpoint serves both authenticated and anonymous traffic. The handler must check userStatus before using userId:

typescript
import { buildValidatedResponse, UserStatus } from '@mantleframework/core'
import { defineApiHandler, z } from '@mantleframework/validation'

const QuerySchema = z.object({ status: z.string().optional().default('downloaded') })

const api = defineApiHandler({ auth: 'authorizer-optional', querySchema: QuerySchema, operationName: 'ListFiles' })
export const handler = api(async ({ context, userId, userStatus, query }) => {
  if (userStatus === UserStatus.Anonymous) {
    // Return demo content for anonymous users
    return buildValidatedResponse(context, 200, { contents: [getDefaultFile()], keyCount: 1 }, schema)
  }

  // userId is string here (but TS can't narrow it from userStatus check)
  const files = await getFilesByUser(userId as string)
  return buildValidatedResponse(context, 200, { contents: files, keyCount: files.length }, schema)
})

OpenAPI Registry

getOpenApiRegistry()

Returns a read-only snapshot of all OpenAPI metadata registered by defineApiHandler calls. The CLI mantle generate openapi command consumes this registry.

typescript
import { getOpenApiRegistry } from '@mantleframework/validation'

const registry = getOpenApiRegistry() // ReadonlyMap<string, OpenApiRegistryEntry>

Re-exports

The package re-exports z and related types from Zod for convenience:

typescript
import { z } from '@mantleframework/validation'
import type { ZodSchema, ZodError, ZodType } from '@mantleframework/validation'