# Object field (/docs/schema/fields/object)

---
title: Object field
description: A fixed set of nested fields in one jsonb column, where every key carries its own schema, label, locale and access rule.
kind: reference
package: questpie
---

| Surface               | Detail                                                    |
| --------------------- | --------------------------------------------------------- |
| Signature             | `f.object(fields)`                                        |
| Argument              | `Record<string, Field>`, one entry per key, required      |
| Column                | `jsonb`                                                   |
| Schema                | `z.object()` composed from each nested field's own schema |
| Filter operators      | the object set, eleven entries, [below](#filtering)       |
| Admin form            | a collapsible panel holding the nested controls           |
| Admin cell            | the first two keys inline, up to twelve behind a tooltip  |
| Type-specific methods | none, the shared chain is the whole surface               |

```ts title="src/questpie/server/collections/users.ts"
import { collection } from "#questpie/factories";

export const users = collection("users").fields(({ f }) => ({
	email: f.email().required(),
	address: f.object({
		street: f.text().required(),
		city: f.text().required(),
		zip: f.text(10),
	}),
}));
```

`address` reads back as `{ street: string; city: string; zip: string | null } | null`,
one `jsonb` value, no second table and no join.

## Nested keys

Each value in the record is a field built from the same `f`, so it takes the
same chain. Another `f.object()` nests, to any depth. What a modifier on a
nested key actually changes:

| On a nested key              | What it does                                                     |
| ---------------------------- | ---------------------------------------------------------------- |
| `.required()`                | Required in the composed schema, non-null in the read type       |
| `.default(v)`                | Marks the key optional on input. No column exists to hold `v`    |
| `.label()`, `.description()` | Labels that key's control inside the panel                       |
| `.localized()`               | Moves that one key to the i18n table, [below](#localization)     |
| `.access(rules)`             | Evaluated at the dot path, `address.zip`                         |
| `.outputFalse()`             | Key leaves the read type and is stripped from reads              |
| `.inputFalse()`              | Writing the key is rejected with a 403                           |
| `.array()`                   | That key holds a list, composed as `z.array()`                   |
| Type refinements             | Fold into the composed schema, `f.text(10)` caps that key at ten |

<Callout type="info" title="Undeclared keys are dropped">
	The composed `z.object()` strips keys you did not declare. A client that posts
	an extra key gets it removed before the write, with no error. Declare every
	key you intend to store.
</Callout>

## Filtering

The typed `where` clause carries the object operator set. Every entry is
structural, and all of them run against the whole `jsonb` value.

| Operator      | Operand                   | SQL                                      |
| ------------- | ------------------------- | ---------------------------------------- |
| `contains`    | a partial object          | `col @> $1::jsonb`                       |
| `containedBy` | an object                 | `col <@ $1::jsonb`                       |
| `hasKey`      | `string`                  | `col ? $1`                               |
| `hasKeys`     | `string[]`                | `col ?& ARRAY[keys]::text[]`             |
| `hasAnyKeys`  | `string[]`                | `col ?\| ARRAY[keys]::text[]`            |
| `pathEquals`  | `{ path: string[]; val }` | `col #>> ARRAY[path]::text[] = $val`     |
| `jsonPath`    | `string`                  | `col @@ $1::jsonpath`                    |
| `isEmpty`     | `boolean`                 | `col = '{}'::jsonb OR col IS NULL`       |
| `isNotEmpty`  | `boolean`                 | `col != '{}'::jsonb AND col IS NOT NULL` |
| `isNull`      | `boolean`                 | `col IS NULL`, inverted on `false`       |
| `isNotNull`   | `boolean`                 | `col IS NOT NULL`, inverted on `false`   |

```ts
const { docs } = await app.collections.users.find({
	where: { address: { pathEquals: { path: ["city"], val: "Berlin" } } },
});

await app.collections.users.find({
	where: { address: { contains: { city: "Berlin", zip: "10115" } } },
});
```

Three sharp edges in that table. `pathEquals` extracts with `#>>`, so `val` is
compared as text and a number has to be written the way Postgres prints it.
`hasKey` and its two siblings see top-level keys only, never a key one level
down. `isEmpty` and `isNotEmpty` ignore their operand, so passing `false` runs
the same SQL as passing `true`.

<Callout type="warn" title="There is no per-key operator">
	`where: { address: { city: { eq: "Berlin" } } }` compiles to nothing. The
	condition is dropped and the query returns rows as though you had not
	filtered. Use `pathEquals` or `contains`.
</Callout>

## After `.array()`

`.array()` keeps the single `jsonb` column and stores a list of the object.
`.minItems(n)` and `.maxItems(n)` become real here, as `z.array().min()` and
`.max()`, and are inert on a bare object. The admin swaps the panel for a
repeatable item list.

```ts
links: f
	.object({ label: f.text().required(), url: f.url().required() })
	.array()
	.maxItems(5),
//  read type: { label: string; url: string }[] | null
```

The operator set changes with it. The eleven object operators are gone and the
array set takes over: `containsAll`, `containsAny`, `eq`, `length`, `isEmpty`,
`isNotEmpty`, `isNull`, `isNotNull`. The types offer a ninth, `contains`, with
no operator behind it. It falls through to an `ILIKE` against the `jsonb`
column, which Postgres rejects. See [Arrays](/docs/schema/fields/array).

## Localization

`.localized()` on the object itself moves the whole `jsonb` value into the i18n
table, one stored object per locale.

`.localized()` on a single nested key splits instead. QUESTPIE walks the nested
fields, leaves a marker at that path in the structure column, and stores the
value per locale. It recurses through nested objects and through `.array()`,
where every item is split at the same path.

```ts
profile: f.object({
	name: f.text().required(),        // one value for every locale
	bio: f.textarea().localized(),    // one value per locale
}),
```

<Callout type="warn" title="The outer call wins">
	Mark the object `.localized()` and the per-key split is skipped entirely, so a
	nested `.localized()` below it does nothing. Pick one level.
</Callout>

## Access on nested keys

A nested `.access()` rule registers under its dot path, `address.zip`. Reads
evaluate the rule and delete the key from the response. Writes evaluate it per
key present in the payload, and a denied key throws instead of being ignored.
Both recurse into deeper objects and into array items. Both are skipped for a
system-mode call, which is how seeds and internal code still see the key. See
[Access control](/docs/schema/access-control).

## Admin

Both methods below come from `@questpie/admin`, which registers them as field
extensions. They sit on the chain of every field, and only when that module is
enabled. See [Collections and Globals](/docs/admin/collections).

`.admin(config)` sets the panel's shape:

| Option             | Values                          | Default         |
| ------------------ | ------------------------------- | --------------- |
| `wrapper`          | `"flat"`, `"collapsible"`       | `"collapsible"` |
| `layout`           | `"stack"`, `"inline"`, `"grid"` | `"stack"`       |
| `columns`          | number, read by `grid` only     | `2`             |
| `defaultCollapsed` | boolean, read by `collapsible`  | `true`          |

`.form(({ f }) => ({ fields }))` orders the keys instead, and accepts the same
`section` and `tabs` containers the collection form takes. Here `f` is a name
proxy, so `f.street` is the string `"street"`.

With a form set, `layout` and `columns` are never read. Each `section` carries
its own pair, defaulting to `"stack"` and `2`. `wrapper` and `defaultCollapsed`
still apply.

```ts
address: f
	.object({ street: f.text(), city: f.text(), zip: f.text(10) })
	.admin({ defaultCollapsed: false })
	.form(({ f }) => ({
		fields: [
			{ type: "section", label: "Postal", layout: "grid", fields: [f.zip, f.city] },
		],
	})),
```

## Related

- [Fields](/docs/schema/fields), the type table and the shared chain.
- [`f.json()`](/docs/schema/fields/json), the same column with no declared keys.
- [Arrays](/docs/schema/fields/array), what `.array()` does to reads and filters.
- [Blocks](/docs/schema/blocks), when items are different shapes, not one shape.
- [Relations](/docs/schema/relations), when nested data wants its own rows.
- [Validation](/docs/schema/validation), the derived schema and the `.zod()` hatch.
