# JSON field (/docs/schema/fields/json)

---
title: JSON field
description: 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.
kind: reference
package: questpie
---

| 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](#filtering) |
| 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              |

```ts title="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()`](/docs/schema/fields/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.

```ts
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(),
```

<Callout type="warn" title="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.
</Callout>

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 |

```ts
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.

<Callout type="warn" title="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()`.
</Callout>

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.

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

<Callout type="warn" title="`.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.
</Callout>

## 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](/docs/schema/fields), the type table and the shared chain.
- [`f.object()`](/docs/schema/fields/object), the same column with declared keys
  and structural filters.
- [Validation](/docs/schema/validation), the derived schema and the `.zod()`
  hatch.
- [Arrays](/docs/schema/fields/array), what `.array()` does to reads and filters.
