Utilities
Keryx provides several Zod helper utilities in packages/keryx/util/zodMixins.ts for common patterns. App-specific helpers like zUserIdOrModel() and zMessageIdOrModel() live in example/backend/util/zodMixins.ts.
secret(schema)
Marks a Zod schema as secret so the field is redacted as [[secret]] in request logs. Uses Zod v4's native .meta() API.
import { secret } from "keryx";
inputs = z.object({
email: z.string().email(),
password: secret(z.string().min(8)),
});When a request comes in with password: "hunter2", the logs will show password: [[secret]].
isSecret(schema)
Check if a Zod schema has been marked as secret:
import { isSecret } from "keryx";
if (isSecret(schema)) {
// redact this field in output
}zBooleanFromString()
Creates a Zod schema that accepts both boolean and string values, transforming "true" and "false" strings into actual booleans. Useful for HTML form data where booleans arrive as strings.
import { zBooleanFromString } from "keryx";
inputs = z.object({
active: zBooleanFromString(),
});
// Accepts: true, false, "true", "false"
// Returns: booleanpaginationInputs()
Creates a Zod schema for pagination inputs with sensible defaults. Returns { page, limit } where page is 1-indexed. Accepts an optional configuration object for custom defaults and bounds.
import { paginationInputs } from "keryx";
// Use defaults: page=1, limit=25, maxLimit=100
inputs = paginationInputs();
// Custom defaults
inputs = paginationInputs({ defaultLimit: 10, maxLimit: 50 });Both page and limit use z.coerce.number() so they work with query string parameters out of the box.
| Option | Default | Description |
|---|---|---|
defaultLimit | 25 | Default items per page when limit is not provided |
maxLimit | 100 | Maximum allowed value for limit |
paginate()
Applies pagination to a Drizzle select query and returns a standardized envelope. Runs the data query and a count query in parallel via Promise.all.
import { paginate, type PaginatedResult } from "keryx";function paginate<T>(
query, // Drizzle select query (before .limit()/.offset())
countQuery, // Promise resolving to [{ count: number }]
params, // { page, limit } from paginationInputs()
): Promise<PaginatedResult<T>>The response envelope:
interface PaginatedResult<T> {
data: T[];
pagination: {
page: number; // Current page (1-indexed)
limit: number; // Items per page
total: number; // Total matching records
pages: number; // Total pages (ceil(total / limit))
};
}Full example
import {
type Action,
type ActionParams,
api,
HTTP_METHOD,
paginate,
paginationInputs,
} from "keryx";
import { count, desc, eq } from "drizzle-orm";
import { messages } from "../schema/messages";
import { users } from "../schema/users";
export class MessagesList implements Action {
name = "messages:list";
description = "List messages with pagination.";
web = { route: "/messages/list", method: HTTP_METHOD.GET };
inputs = paginationInputs({ defaultLimit: 10 });
async run(params: ActionParams<MessagesList>) {
const result = await paginate(
api.db.db
.select({ id: messages.id, body: messages.body, user_name: users.name })
.from(messages)
.orderBy(desc(messages.id))
.leftJoin(users, eq(users.id, messages.user_id)),
api.db.db.select({ count: count() }).from(messages),
params,
);
return { messages: result.data, pagination: result.pagination };
}
}GET /api/messages/list?page=2&limit=5 returns:
{
"messages": [ ... ],
"pagination": { "page": 2, "limit": 5, "total": 23, "pages": 5 }
}The count query is a separate argument so you have full control — use different JOINs, add WHERE clauses, or skip joins that don't affect the total count.
zIdOrModel() Factory
A generic factory that creates a Zod schema accepting either a numeric ID or a full model object. If an ID is provided, it resolves to the full model via a database lookup using an async Zod transform.
function zIdOrModel<TTable extends TableWithId, TModel>(
table: TTable, // Drizzle table definition (must have `id` column)
modelSchema: z.ZodType<TModel>, // Zod schema for the model
isModel: (val: unknown) => val is TModel, // Type guard function
entityName: string, // For error messages
);Throws a TypedError if the ID doesn't match any record.
zUserIdOrModel()
Pre-built helper for the users table:
import { zUserIdOrModel } from "../util/zodMixins";
inputs = z.object({
user: zUserIdOrModel(),
});
// Accepts: 1, 42, or a full User object
// Returns: User (resolved from DB if ID was provided)zMessageIdOrModel()
Pre-built helper for the messages table:
import { zMessageIdOrModel } from "../util/zodMixins";
inputs = z.object({
message: zMessageIdOrModel(),
});
// Accepts: 1, 42, or a full Message object
// Returns: Message (resolved from DB if ID was provided)Creating Your Own
To create a resolver for a custom table, use the zIdOrModel factory directly:
import { zIdOrModel } from "keryx";
import { createSchemaFactory } from "drizzle-zod";
import { z } from "zod";
import { projects, type Project } from "../schema/projects";
const { createSelectSchema } = createSchemaFactory({ zodInstance: z });
const zProjectSchema = createSelectSchema(projects);
function isProject(val: unknown): val is Project {
return zProjectSchema.safeParse(val).success;
}
export function zProjectIdOrModel() {
return zIdOrModel(
projects,
zProjectSchema as z.ZodType<Project>,
isProject,
"Project",
);
}Auto-Generated Drizzle Schemas
The Zod schemas for database models are auto-generated from Drizzle table definitions using drizzle-zod:
import { createSchemaFactory } from "drizzle-zod";
const { createSelectSchema } = createSchemaFactory({ zodInstance: z });
export const zUserSchema = createSelectSchema(users);
export const zMessageSchema = createSelectSchema(messages);These stay in sync with the database schema automatically — when you add a column to the Drizzle table, the Zod schema updates too.