# Array field (/docs/schema/fields/array)

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

| Aspect         | Before `.array()`        | After                            |
| -------------- | ------------------------ | -------------------------------- |
| Column         | the inner field's column | `jsonb`, whatever the inner type |
| Row value      | `T`                      | `T[]`                            |
| Derived schema | the inner schema         | `z.array(inner)`                 |
| Operator set   | the inner set            | `selectMultiOps`, membership     |
| `getType()`    | `"text"`, `"number"`, …  | `"array"`                        |
| Admin control  | the inner control        | `ArrayField`, a repeatable list  |

It takes no arguments and adds no state past the wrapping. `.required()` and
`.minItems()` land the same on either side of it, so
`f.text().required().array()` and `f.text().array().required()` build one field.

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

export const posts = collection("posts").fields(({ f }) => ({
	tags: f.text(40).array().maxItems(10), // string[] | null
	ratings: f.number().array().required(), // number[]
	steps: f.object({ label: f.text() }).array(), // { label: string | null }[] | null
}));
```

Run `questpie generate` then `questpie push`. Each of those is one `jsonb`
column. No junction table, no extra rows.

## Methods

Two methods only mean something on an array. Both sit on the base field class,
so they are callable before `.array()` and on a scalar field, where nothing
reads them.

| Method         | Effect                                                                        |
| -------------- | ----------------------------------------------------------------------------- |
| `.minItems(n)` | `z.array(…).min(n)` on writes, and the floor the admin remove button stops at |
| `.maxItems(n)` | `z.array(…).max(n)` on writes, and the cap the admin add button stops at      |

Shared modifiers apply to the list as a whole. `.required()` makes the column
`NOT NULL` and the key required on insert.
`.localized()` moves the jsonb column into the `<collection>_i18n` table, so
each locale holds its own list. Per-element validation survives the wrapping,
and `f.text(40).array()` still rejects a 41-character element.

<Callout type="warn" title="Put `.default()` after `.array()`">
	`.default()` keeps the value it was handed and writes it straight onto the
	column. `f.text().array().default([])` gives the column an empty list.
	`f.text().default("").array()` gives it the string, because the default was
	set while the field was still a scalar.
</Callout>

## Item types

Wrapping a field that has a column always gives the same `jsonb` column and the
same `z.array(inner)`. What differs is the control the admin picks for one item.

| Inner field                             | Item control                                |
| --------------------------------------- | ------------------------------------------- |
| `f.text()`, `f.textarea()`, `f.email()` | The matching input                          |
| `f.number()`                            | A number input                              |
| `f.select(options)`                     | One dropdown per item, carrying the options |
| `f.object(fields)`                      | A repeatable nested form, one per item      |
| anything else                           | A plain text input                          |

That allowlist is five names plus the object branch above it. A type outside it
never sets `itemType`, and `ArrayField` defaults that to `"text"`. So
`f.boolean().array()` gets a text input, and the strings it produces are what
`z.array(z.boolean())` rejects on save.

<Callout type="warn" title="Not for relations or uploads">
	QUESTPIE registers a relation by reading `getMetadata().type`, and `.array()`
	rewrites that to `"array"`. A wrapped `f.relation(to)` or `f.upload()` loses
	its foreign key, its hydration and its picker, and keeps a bare jsonb column.
	Reach for `f.relation(to).multiple()` or `f.upload({through})` instead.
</Callout>

## Reads and writes

On read, each element is hydrated through the inner field, so
`f.datetime().array()` hands back `Date` objects rather than strings. A stored
value that is not a JSON array throws a `TypeError`, which means a hand-written
migration leaving a scalar in the column breaks every read of that row.

On write, the field's own Zod schema overlays the column-derived one. That is
what makes `z.array(inner)` and the length bounds enforced rather than
decorative. A value that arrives as a string but parses as a JSON array is
decoded before it reaches the driver, so a list that was already stringified
once is stored as an array instead of being encoded a second time.

## Filtering

An array carries `selectMultiOps`. Element operands are typed off the inner
field, so `f.number().array()` filters on `number` and `f.datetime().array()` on
the datetime input type.

| Operator               | Operand   | Compiles to                                     |
| ---------------------- | --------- | ----------------------------------------------- |
| `containsAll`          | `Item[]`  | `col @> '[…]'::jsonb`, holds every listed value |
| `containsAny`          | `Item[]`  | `col ?\| ARRAY[…]::text[]`, holds at least one  |
| `eq`                   | `Item[]`  | `col = '[…]'::jsonb`, the whole list in order   |
| `length`               | `number`  | `jsonb_array_length(col) = n`                   |
| `isEmpty`              | `boolean` | `col = '[]'::jsonb OR col IS NULL`              |
| `isNotEmpty`           | `boolean` | `col != '[]'::jsonb AND col IS NOT NULL`        |
| `isNull` / `isNotNull` | `boolean` | Pass `false` and each inverts                   |

```ts
const { docs } = await app.collections.posts.find({
	where: { tags: { containsAll: ["ts", "cms"] } },
});
```

`containsAll` and `eq` serialize the operand as JSON, so element types survive.
`containsAny` casts it to `text[]` for the `?|` operator.

<Callout type="warn" title="`contains` is typed but is not an array operator">
	The generated where type lists a single-element `contains`. `selectMultiOps`
	has none, so the builder falls through to its generic `contains` and emits
	`ILIKE '%value%'` against a jsonb column. Use `containsAny: [value]` to match
	one element.
</Callout>

<Callout type="warn" title="`isEmpty` and `isNotEmpty` ignore their operand">
	Both take the column and never read the value, so `isEmpty: false` compiles to
	the same SQL as `isEmpty: true`. Pass `isNotEmpty: true` for the opposite.
	`isEmpty` is also unparenthesized, and the builder brackets only the whole
	`AND` group, so beside another filter its `IS NULL` arm escapes.
</Callout>

<Callout type="warn" title="Two inner types lose the array filter type">
	`select` and `json` declare a `whereInput` on their state and `.array()`
	carries it through. So `f.select(…).array()` types its `where` as one scalar,
	`eq` / `ne` / `in` / `notIn`, while the runtime set is `selectMultiOps`. The
	membership operators are unreachable through the typed clause.
</Callout>

## In the admin

The form control is `ArrayField`, from `@questpie/admin`, a numbered list of the
item control with an add button under it. `.minItems()` and `.maxItems()` reach
it and gate the remove and add buttons. The list cell is `ArrayCell`, a count
badge beside the first item, with up to ten items in a hover tooltip.

Configure the rest with `.admin()`, the field extension that module registers.

| Key         | Type                              | Default    | Effect                                      |
| ----------- | --------------------------------- | ---------- | ------------------------------------------- |
| `orderable` | `boolean`                         | `false`    | Adds the move up and move down buttons      |
| `minItems`  | `number`                          | none       | Same floor, set from the admin side         |
| `maxItems`  | `number`                          | none       | Same cap, set from the admin side           |
| `mode`      | `"inline" \| "modal" \| "drawer"` | `"inline"` | Where an item is edited, object items only  |
| `layout`    | `"stack" \| "inline" \| "grid"`   | `"stack"`  | How an item's fields sit, object items only |
| `columns`   | `number`                          | `2`        | Grid width, object items only               |
| `itemLabel` | `string`                          | none       | The item header, object items only          |

```ts
steps: f.object({ label: f.text() }).array().admin({ orderable: true }),
```

The last four are read only by the object-item component. A primitive array
takes `orderable`, `minItems` and `maxItems` and drops the rest.

## Types

An array contributes `Item[]` to the row, insert and update shapes, and the
element type comes from the inner field.

```ts
type Post = typeof posts.$infer.select;
//   ^? { tags: string[] | null; ratings: number[]; ... }
```

`.required()` makes it non-null and required on insert, in either chain order.
`.default()` makes it optional on insert. `.minItems()` and `.maxItems()` are
runtime validation and have no type-level effect.

## Related

[Fields](/docs/schema/fields) has the table of every type you can wrap.

[Object](/docs/schema/fields/object) is the inner field for a list of structured
items, and the only one the admin gives a real nested form.

[Relations](/docs/schema/relations) is where a list of other rows belongs.

[Collaborative documents](/docs/schema/collaborative-documents) covers
`.crdt({ format: "set", conflict: "add-wins" })`, which takes an array of
`f.text({ mode: "text" })` that is required, defaults to `[]`, and carries no
length bounds.

[Validation](/docs/schema/validation) covers the derived schema and `.zod()`.
