QUESTPIE
SchemaFields

JSON field

One jsonb column with no declared keys, for data whose shape you do not control. Three ways to type the value, and only one of them also narrows the filter.

View markdown
SurfaceDetail
Signaturef.json<T>(config?)
Config{ mode?: "jsonb" | "json" }, optional
Columnjsonb, or json under { mode: "json" }
Schemaa recursive JSON union, not z.any()
Filter operatorssix, none reaching inside the value, below
Admin forma monospace textarea with a Format button
Admin cellfifty characters, the whole value in a title attribute
Type-specific methodsnone, the shared chain is the whole surface
src/questpie/server/collections/posts.ts
import { collection } from "#questpie/factories";

export const posts = collection("posts").fields(({ f }) => ({
	metadata: f.json(), // any JSON value
	settings: f.json<{ theme: "light" | "dark" }>(), // narrowed
}));

metadata reads back as JsonValue, settings as { theme: "light" | "dark" } | null. JsonValue already includes null, so .required() does not change that read type. Each field is one jsonb column. There is no second table and no join. JsonValue is exported from questpie.

Reach for it when the shape is genuinely open. Use f.object() when the keys are fixed. It takes the same column and gives every key its own schema, label, locale and access rule. It also swaps the operator set. You lose eq, ne, in and notIn. You gain nine structural operators this type does not have.

The mode

CallColumn
f.json()jsonb
f.json({ mode: "jsonb" })jsonb
f.json({ mode: "json" })json

The derived schema and the operator set are identical in both. The argument moves the Postgres column type and nothing else.

Typing the value

The type argument, .$type() and .zod() all move the value type. They differ on the filter and on the runtime check.

FormRead typeFilter operandRuntime check
f.json()JsonValueJsonValuethe JSON union
f.json<T>()T | nullTthe JSON union
.$type<T>()T | nullunchangedthe JSON union
.zod(fn)the schema outputunchangedthe schema

The field fixes its filter operand when the factory runs. Unchanged means whatever the type argument said. That is JsonValue when you passed none. A later .$type() or .zod() cannot reopen it.

.zod() narrows the read type only when the schema it returns has a real output type. Return a bare ZodType and the field keeps the type it had, with the new validation on top.

import { z } from "zod";

preferences: f
	.json<{ theme: "light" | "dark"; density: number }>()
	.zod(() =>
		z.object({
			theme: z.enum(["light", "dark"]),
			density: z.number().int().min(1).max(5),
		}),
	)
	.required(),

A type argument alone accepts anything

f.json<{ theme: "light" | "dark" }>() moves the TypeScript type only. The schema stays the loose JSON union, so a write of { theme: "blue" } is stored. Add .zod() to make the shape a rule.

Without .required() the column is nullable and the input optional. The derived schema is the recursive union of string, number, boolean, null, arrays and objects. A Date, a Map or a function fails validation instead of passing through.

Filtering

The JSON field carries the basic operator set. No entry reaches inside the stored value.

OperatorOperandMatches
eq / nethe whole valueEqual, not equal
in / notInarray of whole valuesIn the list, not in the list
isNull / isNotNullbooleanPass false and each inverts
const { docs } = await app.collections.posts.find({
	where: { settings: { eq: { theme: "dark" } } },
});

await app.collections.posts.find({
	where: { metadata: { isNull: true } },
});

The runtime set carries a seventh operator, not. The field's declared filter input leaves it out. It runs. It does not type-check.

Filter by key and the clause is dropped

where: { settings: { theme: "dark" } } is a type error. If you cast past it, QUESTPIE finds no operator, compiles nothing, and returns every row. Use eq, or model the key with f.object().

An object under a field key is always read as an operator map. QUESTPIE never treats it as the stored value itself. Only a scalar survives as a bare shorthand. On an untyped field where: { metadata: "ready" } type-checks and compiles to equality.

Lists

A JSON value is already allowed to be an array, so a list needs no .array(). Say so in the type argument.

tags: f.json<string[]>(),
events: f.json<Array<{ at: string; type: string }>>(),

`.array()` splits the filters in two

It swaps the runtime operator set for the array one: containsAll, containsAny, length and the rest. The field still declares the JSON filter input. What you can type and what runs stop agreeing.

Localization

.localized() moves the whole value into the i18n table, one JSON document per locale. There is no per-key split here, because there are no declared keys. A write replaces that locale's document outright.

The control renders no locale indicator. JsonField builds its own label row and never reads the localized prop. Storage is unaffected.

In the admin

The form control is JsonField. It prints the stored value into a monospace textarea with a two space indent. It parses on every keystroke. A draft that does not parse goes to the form as the raw string, and validation refuses it. Emptying the box writes null. The Format button reprints the value.

The list cell is JsonCell. It prints compact JSON, cut at fifty characters, with the whole value in a title attribute. A null shows a dash.

.admin() reaches the control:

OptionValuesDefault
minHeightpixels200
maxHeightpixels, 0 for no limit400
defaultMode"code", "form""code"
allowModeSwitchbooleantrue

The last two feed a second editing mode. That mode renders only when the control also gets a renderForm render prop. renderForm returns React nodes. Admin metadata crosses to the browser as JSON, so a collection file cannot send one. allowModeSwitch is therefore dead on a declared field. defaultMode is not quite dead. Setting it to "form" still shows the code editor. It also turns off the invalid-JSON form error, which only runs in code mode.

JsonFieldAdminMeta types only codeEditor, a flag nothing reads. The other four still arrive, because .admin() takes unknown on every field.

  • Fields, the type table and the shared chain.
  • f.object(), the same column with declared keys and structural filters.
  • Validation, the derived schema and the .zod() hatch.
  • Arrays, what .array() does to reads and filters.

On this page