Fields
Fields are the typed building blocks of a collection, each one declares a column, a Zod schema, an operator set, and an admin component, all from a single chained factory.
Fields define the shape of your data. Inside a collection, global, or block you call .fields(({ f }) => ({ ... })) and assign each key a field built from the f proxy: f.text(255), f.relation("author"), f.datetime().autoNow(). From that one declaration QUESTPIE derives the Postgres column, a runtime Zod validation schema, the WHERE-clause operators for filtering, the insert/select/where TypeScript types, and the admin UI input, so you describe a field once and get the whole stack typed end to end.
What it does
- Generates a typed column, each field produces a Drizzle/Postgres column with the right type, nullability, and default.
- Validates input, every field derives a Zod schema (length, range, pattern, format) applied on create and update.
- Drives type-safe filters, each field ships an operator set (
eq,gt,contains, …) that types thewhereclause for that field. - Renders an admin input, the field's type picks the editor (text box, date picker, relation selector, rich-text editor) and
.admin()tunes it. - Infers insert/select types,
.required(),.default(),.outputFalse(),.virtual()flow into the generatedCollectionDoc/ insert types with no extra annotation. - Composes by chaining, every modifier returns a new immutable field, so
f.text(255).required().localized().admin({ ... })reads top to bottom and stays type-safe at each step.
Quick start
f is the field builder. You never import it, it is the first argument of the .fields() callback. Each property on f is a factory; the positional argument configures the column, and you chain modifiers to refine it.
import { collection } from "#questpie/factories";
export const posts = collection("posts").fields(({ f }) => ({
title: f.text(255).required(),
slug: f.text(255).required().inputOptional(),
excerpt: f.textarea(),
views: f.number().default(0),
published: f.boolean().default(false),
publishedAt: f.datetime(),
author: f.relation("user").required(),
// Full field types + every chain method are documented below.
}));That gives you a posts table with the right columns, a validation schema, typed filters, and an admin form, no migrations to hand-write, no types to declare.
f is a proxy over the registered field types. Accessing an unknown type throws at build time, Unknown field type: "x". Available types: …, so typos surface immediately.
Example → inferred types
Each field flows into the generated collection types. A notNull field with no default is required on insert; .default() makes it optional; .inputOptional() keeps an otherwise-required field optional; .virtual() and computed fields drop out of insert entirely.
const posts = collection("posts").fields(({ f }) => ({
title: f.text(255).required(), // required on insert, string on read
views: f.number().default(0), // optional on insert (has default)
slug: f.text(255).required().inputOptional(), // NOT NULL column, optional input
author: f.relation("user").required(),// FK column, required id string
}));
// After `questpie generate`, the typed client returns rows shaped like:
// {
// id: string;
// title: string;
// views: number;
// slug: string;
// author: string; // the related id
// createdAt: Date; updatedAt: Date; // from the base collection
// }How the type maps are derived (read-only reference): ExtractInputType / ExtractSelectType / ExtractWhereType in packages/questpie/src/server/fields/field-class-types.ts.
Field types
Every type below is reached as f.<type>(…). The constructor arg is positional, there is no options-object constructor. Refine each field with the shared methods plus the type-specific methods listed here.
Each type has its own page with the full constructor, type-specific methods, operator set, and gotchas. This hub is the overview; follow a link for the detail.
| Field | What it stores |
|---|---|
f.text() | Single-line string, a varchar (or unbounded text) with length, pattern, and string filtering. |
f.textarea() | Unbounded multi-line string, a text column rendered as a multiline editor. |
f.email() | String validated as an email address, with domain-aware filters. |
f.url() | Web address, a varchar(2048) with URL-format validation and host/protocol filters. |
f.number() | Numeric, integer, smallint, bigint, real, double, or fixed-precision decimal, with value bounds. |
f.boolean() | True/false, a boolean column with equality / null filtering. |
f.date() | Calendar date (no time) as an ISO date string, with auto-now stamping. |
f.datetime() | Instant in time, a timestamp reading back as a JS Date, with precision and timezone. |
f.time() | Time of day (HH:MM:SS) in a time column. |
f.select() | Enumerated string, read type narrows to the literal union of the option values. |
f.upload() | Typed relation to an upload collection, single file, gallery, or inline asset IDs. |
f.object() | Fixed, typed nested shape in one jsonb column. |
.array() | Wraps any field into a list stored as jsonb, with length bounds. |
f.json() | Schema-less jsonb (or json) for free-form data you narrow and validate yourself. |
f.richText() | TipTap WYSIWYG stored as structured JSONB (or markdown). Admin module only. |
Text & string
| Field | Constructor | Column | Read type | Type-specific methods |
|---|---|---|---|---|
f.text(maxLength?) | maxLength number (default 255) or { mode: "text" } for unbounded | varchar(255), or text in { mode: "text" } | string | .pattern(re) .trim() .lowercase() .uppercase() .min(n) .max(n) |
f.textarea() | none | text (unbounded) | string | .min(n) .max(n) |
f.email(maxLength?) | maxLength number (default 255) | varchar(maxLength) | string | .min(n) .max(n) |
f.url(maxLength?) | maxLength number (default 2048) | varchar(maxLength) | string | .min(n) .max(n) |
title: f.text(255).required(),
body: f.text({ mode: "text" }), // unbounded Postgres text
summary: f.textarea(), // multiline editor in admin
contact: f.email().required(), // validated z.string().email()
website: f.url(), // validated z.string().url(), len 2048For text/textarea/email/url, .min(n) / .max(n) constrain string length (they map to minLength / maxLength). email and url add .email() / .url() format validation automatically. Sources: packages/questpie/src/server/modules/core/fields/{text,textarea,email,url}.ts.
.trim(), .lowercase(), and .uppercase() set runtime state but are not currently applied to the Zod schema, derive-schema.ts only emits maxLength / minLength / pattern for strings, so these three do not transform or validate the value yet. To normalize input, use a .zod() refinement or a field hook instead.
Number
| Field | Constructor | Column | Read type | Type-specific methods |
|---|---|---|---|---|
f.number(mode?) | mode: "integer" (default) | "smallint" | "bigint" | "real" | "double", or { mode: "decimal"; precision?; scale? } | integer / smallint / bigint / real / doublePrecision / numeric(10, 2) | number | .min(n) .max(n) .positive() .int() .step(n) |
quantity: f.number().required(), // integer (default mode)
rating: f.number("smallint").min(1).max(5),
price: f.number({ mode: "decimal", precision: 10, scale: 2 }),
weight: f.number("double").positive(),Default mode is "integer". bigint and decimal use Drizzle's mode: "number", so they read back as JS number (not bigint/string). .step(n) adds a value % n === 0 refinement. For numbers, .min() / .max() set value bounds (distinct from the string-length .min() / .max() on text).
Boolean
| Field | Constructor | Column | Read type | Type-specific methods |
|---|---|---|---|---|
f.boolean() | none | boolean | boolean | none |
isActive: f.boolean().default(true).required(),No type-specific methods, refine with the shared methods only.
Date & time
| Field | Constructor | Column | Read type | Type-specific methods |
|---|---|---|---|---|
f.date() | none | date (mode: "string") | string (ISO date) | .autoNow() .autoNowUpdate() |
f.datetime(config?) | { precision?: 0-6; withTimezone?: boolean } | timestamp (precision 3, withTimezone: true) | Date | .autoNow() .autoNowUpdate() |
f.time(config?) | { precision?: 0-6; withSeconds?: boolean } | time (precision 0) | string (HH:MM:SS) | none |
createdAt: f.datetime().autoNow().inputFalse(), // set once on create
updatedAt: f.datetime().autoNowUpdate().inputFalse(), // refreshed on every update
birthday: f.date(), // ISO date string
opensAt: f.time(), // "09:00:00"datetime reads back as a real Date; date and time read back as strings. .autoNow() sets a () => new Date() default; .autoNowUpdate() adds a beforeChange hook that stamps the current time on every write. The classic pair is .autoNow().inputFalse() for createdAt and .autoNowUpdate().inputFalse() for updatedAt. Sources: packages/questpie/src/server/modules/core/fields/{date,datetime,time}.ts.
Select
| Field | Constructor | Column | Read type | Type-specific methods |
|---|---|---|---|---|
f.select(options) | readonly SelectOption[] (static) or an OptionsConfig (dynamic) | varchar (sized to the longest value, min 50; 255 for dynamic) | literal union of values (static) / string (dynamic) | .enum(name) |
A SelectOption is { value: string \| number; label: I18nText; description?; disabled?; icon?; className? }.
// Static options narrow the read type to the literal value union:
status: f.select([
{ value: "draft", label: "Draft" },
{ value: "published", label: { en: "Published", sk: "Publikované" } },
]).required().default("draft"),
// ^ read type is "draft" | "published"
// Multi-select = a select with .array():
tags: f.select([
{ value: "news", label: "News" },
{ value: "guide", label: "Guide" },
]).array(),Passing static options narrows the field's read type to the literal union of those values. Multi-select is .array(), there is no separate multi factory. Dynamic, server-driven options (search + pagination) use an OptionsConfig { handler, deps? }; the read type then widens to string. .enum(name) creates a fresh pgEnum from the option values. Sources: packages/questpie/src/server/modules/core/fields/select.ts, packages/questpie/src/server/fields/reactive-types.ts.
Relation
| Field | Constructor | Default column | Read type | Transition methods |
|---|---|---|---|---|
f.relation(target) | collection name "user", lazy () => ({ name }), or polymorphic map { users: "users", posts: "posts" } | varchar(36) FK (belongsTo) | string (the related id) | .hasMany() .manyToMany() .multiple() .onDelete() .onUpdate() .relationName() |
// belongsTo, owns a FK column, reads back as the related id:
author: f.relation("user").required().onDelete("cascade"),
// hasMany, no own column; the FK lives on the target table:
posts: f.relation("post").hasMany({ foreignKey: "author" }),
// manyToMany, through a junction collection:
services: f.relation("service").manyToMany({
through: "barber_services",
sourceField: "barber",
targetField: "service",
}),The default form is belongsTo: it owns a varchar(36) foreign-key column and reads back as a single id string. The transition methods change the relation kind:
.hasMany({ foreignKey, onDelete?, relationName? }), virtual, no own column; the FK lives on the target table. Reads back asstring[]..manyToMany({ through, sourceField?, targetField?, relationName? }), virtual, joined through a junction collection. Reads back asstring[]..multiple(), owns ajsonbarray of FK ids on this table (not virtual). Reads back asstring[]..onDelete(action)/.onUpdate(action), referential action:"cascade" \| "set null" \| "restrict" \| "no action"..relationName(name), disambiguates multiple relations between the same two collections.
The target collection name is checked against your registry once codegen runs, so a typo'd target fails to type-check.
.hasMany(), .manyToMany(), .multiple() are transition methods, they re-shape the field's type state. Chain them early; the order-preserving .onDelete() / .onUpdate() / .relationName() keep whatever state you have accumulated.
Upload
| Field | Constructor | Default column | Read type | Type-specific methods |
|---|---|---|---|---|
f.upload(config?) | { to?; mimeTypes?; maxSize?; through?; sourceField?; targetField? } | varchar(36) FK to the upload collection (default "assets") | string (asset id) | .multiple() |
avatar: f.upload({ to: "assets", mimeTypes: ["image/*"], maxSize: 5_000_000 }),
gallery: f.upload({ to: "assets" }).multiple(), // inline jsonb array of asset idsupload is a relation specialised for files, by default it owns a varchar(36) FK to the assets collection and the admin renders an upload widget (not a relation picker). Setting through makes it many-to-many (virtual); .multiple() stores an inline jsonb array of asset ids.
mimeTypes and maxSize are captured as admin metadata, not enforced as column constraints. They guide the upload UI; enforce hard limits in your storage adapter or a field hook.
Structured
| Field | Constructor | Column | Read type | Notes |
|---|---|---|---|---|
f.object(fields) | Record<string, Field> of nested fields | jsonb | inferred object from the nested fields | nested outputFalse()/virtual fields drop out of the shape |
f.json(config?) | { mode?: "jsonb" | "json" } | jsonb (default) or json | JsonValue (narrow with .$type<T>()) | schema-less |
<field>.array() | none (chain method) | jsonb | T[] of the inner read type | combine with .minItems() / .maxItems() |
// object, typed nested shape stored as one jsonb column:
address: f.object({
street: f.text(255),
city: f.text(120),
zip: f.text(10),
}),
// json, schema-less; narrow the type, validate with .zod():
settings: f.json<{ theme: "light" | "dark" }>(),
// array of scalars (or any field):
tags: f.text().array().maxItems(10),Use f.object() when the nested shape is fixed and you want per-key validation; use f.json() when the data is free-form (narrow the type with .$type<T>() and add runtime validation with .zod()).
Custom Drizzle column behavior
Keep the QUESTPIE field type and use .drizzle() when the field needs column-level Drizzle behavior that the fluent QUESTPIE method surface does not expose. The callback receives the underlying Drizzle column builder and must return the column builder to use.
import { sql } from "drizzle-orm";
const externalId = f
.text(255)
.drizzle((column) => column.$type<`ext_${string}`>());
const createdAt = f
.datetime()
.drizzle((column) => column.default(sql`now()`));Use .drizzle() for column builder details such as SQL defaults, Drizzle $type<T>(), or a Drizzle option that belongs to the generated column. If the field needs a new storage model, metadata shape, or operator set, create a custom field type with field() / fieldType() instead.
Rich text & blocks (admin module)
| Field | Constructor | Column | Read type |
|---|---|---|---|
f.richText(options?) | { mode?: "json" | "markdown" } (default "json") | jsonb (json) or text (markdown) | TipTapDocument (json) / string (markdown) |
f.blocks() | none | jsonb | BlocksDocument |
bio: f.richText().localized(), // TipTap JSON document
content: f.richText({ mode: "markdown" }),// stored as markdown text
body: f.blocks(), // block-based page builderThese two are contributed by @questpie/admin and are only on f when the admin module is active. richText defaults to a TipTap JSON document (mode: "markdown" stores a markdown string instead). blocks stores a block tree built from your block(name) declarations. Sources: packages/admin/src/server/fields/{rich-text,blocks}.ts.
f.richText() and f.blocks() exist only when the admin module is registered, they ride in on the generated f alongside the 15 core types. Without the admin module, accessing them throws the "Unknown field type" error.
Shared methods
These chain methods are available on every field type. Each returns a new immutable field, so order them freely.
Schema & nullability
| Method | Effect |
|---|---|
.required() | NOT NULL column + required on input (unless it has a default or is inputOptional). |
.default(value) | Column default, literal, a () => value factory, or raw SQL. Makes input optional. Type-checked against the field's data type. |
.zod(fn) | Transform the auto-derived Zod schema (.refine(), or replace it). Returning a concrete output narrows the field's value type. |
.$type<T>() | Type-level only (no runtime effect), pin the value type, mainly for f.json(). Pair with .zod() for runtime validation. |
email: f.email().required(),
status: f.select([...]).default("draft"),
score: f.number().zod((s) => s.refine((n) => n % 5 === 0, "Must be a multiple of 5")),
metadata: f.json<{ flags: string[] }>(),.default(value) is type-constrained to the field's data type, exactly like Drizzle, f.boolean().default("yes") won't compile. For column-level SQL defaults, prefer .drizzle((c) => c.default(sql`now()`)).
Input/output shaping
| Method | Effect |
|---|---|
.inputFalse() | Read-only, excluded from the insert/update input type. |
.inputOptional() | Always optional on input, even if the column is NOT NULL. |
.inputTrue() | Force into the input type even if the field is virtual. |
.outputFalse() | Write-only, dropped from the select/output type (e.g. a password). |
.virtual(expr?) | No DB column; optional SQL expression for a computed value. |
.localized() | Store per-locale values in the i18n side-table. |
.array() | Wrap the value as an array (stored as jsonb). For select, this is multi-select. |
.minItems(n) / .maxItems(n) | Array length bounds, only meaningful after .array(). |
createdAt: f.datetime().autoNow().inputFalse(),
slug: f.text(255).required().inputOptional(), // NOT NULL, but you may omit it (a hook fills it)
passwordHash: f.text().outputFalse(), // accepted on write, never returned
fullName: f.text().virtual(sql`first_name || ' ' || last_name`),
title: f.text(255).localized(), // per-locale value
tags: f.text().array().maxItems(10),Behaviour & access
| Method | Effect |
|---|---|
.label(text) / .description(text) | Admin label/help text. I18nText = string | Record<locale, string>. |
.hooks(hooks) | Per-field beforeChange / afterRead / validate / beforeCreate / beforeUpdate. Each transforms the value (same type in, same type out). |
.access(rules) | Per-field read / create / update access, boolean or (ctx) => boolean | Promise<boolean>. |
.operators(ops) | Override the WHERE operator set for this field. |
.admin(config) | Per-field admin UI config (component, placeholder, layout, …). Admin module only. |
price: f.number({ mode: "decimal" }).label({ en: "Price", sk: "Cena" }),
title: f.text(255).hooks({ beforeChange: (v) => v.trim() }),
secret: f.text().access({ read: ({ user }) => user?.role === "admin" }),
phone: f.text(50).admin({ placeholder: "+1 (555) 000-0000" }),Field afterRead hooks (and f.blocks()'s built-in afterRead) run output processing that can deadlock Bun SQL if executed inside an already-open transaction, the context-inherited tx connection blocks. Keep heavy afterRead/block hydration outside an open tx. See the framework invariant in CLAUDE.md (feedback_save_inside_tx).
Low-level escape hatches
| Method | Effect |
|---|---|
.drizzle(fn) | Customize the underlying Drizzle column builder (.default(sql`...`), .$type<T>()). Applied last when building the column. |
.fromDb(fn) / .toDb(fn) | Pure value mappers at the DB boundary (no type change). |
.set(key, value) | Generic plugin-extension setter (what .admin() / .form() use under the hood). |
createdAt: f.datetime().drizzle((c) => c.default(sql`now()`)),Full method source: packages/questpie/src/server/fields/field-class.ts.
Advanced
Field hook lifecycle
.hooks() runs per field. Hook values are typed to the field's data type and must return the same type (transform, not reshape). On write the order is validate → beforeCreate/beforeUpdate → beforeChange; on read, afterRead. The hook context gives you { field, collection, operation, req?, user?, doc, originalValue?, db, config }.
Custom field types
A new field type is declared with fieldType(name, { create, methods }), the declarative way to add a type with its own chain methods. Every built-in exports a matching <name>FieldType (textFieldType, numberFieldType, relationFieldType, …) that codegen discovers from your fields/ directories.
Custom operator sets
.operators(ops) overrides the WHERE operators for a field. Build a set with operator<TValue>((col, value, ctx) => SQL) / operatorSet(...). The built-in sets, stringOps, numberOps, booleanOps, dateOps, selectSingleOps, belongsToOps, …, live in packages/questpie/src/server/operators/builtin.ts.
TypeScript
Fields are the single origin of your collection's read/insert/where types, you rarely touch field types directly. After questpie generate, import the projected collection types:
import type { CollectionDoc, CollectionWhere } from "#questpie/types";
type Post = CollectionDoc<"posts">; // the read shape
type PostFilter = CollectionWhere<"posts">; // the typed where clauseIf you need the field primitives themselves (building a plugin or a custom field type), they are exported from questpie and questpie/builders: Field, field, fieldType, FieldState, FieldWithMethods, the Extract*Type helpers, and the reactive types. Admin's richText / blocks and their state/document types come from @questpie/admin/fields. Sources: packages/questpie/src/exports/index.ts, packages/admin/src/exports/fields.ts.
You don't import the field factories (text, relation, …) directly in app code, they ride in on the destructured f inside .fields(). Direct factory imports are for library/plugin authors building custom f proxies via createFieldBuilder.
Related
- Collections, where fields are declared and how they project to tables and the admin.
- Relations, belongsTo / hasMany / manyToMany / polymorphic in depth.
- Validation, how the derived Zod schemas run on create and update.
- Hooks, collection- and field-level lifecycle hooks.
- Example:
examples/tanstack-barbershop/src/questpie/server/collections/, real collections using every field type above.
Globals
Globals are single-row data models, site settings, a homepage, a footer, built with the same fields, hooks, and access rules as collections, but with get/update instead of full CRUD.
Relations
Relations link collections with a single typed field, f.relation(target) gives you a foreign key, an admin picker, on-demand `with` hydration, typed relation filters, and nested connect/create writes, with belongsTo, hasMany, many-to-many, multiple, and polymorphic all reached from one factory.