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.
| Surface | Detail |
|---|---|
| Signature | f.json<T>(config?) |
| Config | { mode?: "jsonb" | "json" }, optional |
| Column | jsonb, or json under { mode: "json" } |
| Schema | a recursive JSON union, not z.any() |
| Filter operators | six, none reaching inside the value, below |
| Admin form | a monospace textarea with a Format button |
| Admin cell | fifty characters, the whole value in a title attribute |
| Type-specific methods | none, the shared chain is the whole surface |
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
| Call | Column |
|---|---|
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.
| Form | Read type | Filter operand | Runtime check |
|---|---|---|---|
f.json() | JsonValue | JsonValue | the JSON union |
f.json<T>() | T | null | T | the JSON union |
.$type<T>() | T | null | unchanged | the JSON union |
.zod(fn) | the schema output | unchanged | the 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.
| Operator | Operand | Matches |
|---|---|---|
eq / ne | the whole value | Equal, not equal |
in / notIn | array of whole values | In the list, not in the list |
isNull / isNotNull | boolean | Pass 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:
| Option | Values | Default |
|---|---|---|
minHeight | pixels | 200 |
maxHeight | pixels, 0 for no limit | 400 |
defaultMode | "code", "form" | "code" |
allowModeSwitch | boolean | true |
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.
Related
- 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.
Array field
There is no f.array(). It is a chain method on every field, and it swaps that field's column for one jsonb column holding a list, and its filters for the multi-value set.
Rich text field
The TipTap editor field. It stores a structured document in jsonb, or a markdown string in text, and renders a configurable WYSIWYG editor in the admin.