QUESTPIE
ConceptsFields

Array field

.array() wraps any field type into a list stored as a single jsonb column, with a z.array() schema, length bounds, and membership filtering on the typed where clause.

.array() is the shared modifier that turns any field into a list of that field's values. Chain it onto f.text(), f.number(), f.select(...), and most other types: the column becomes a single Postgres jsonb array, the derived schema becomes z.array(inner), and the where clause swaps to membership operators. The element type is whatever the inner field produced, f.number().array() is a number[], f.text().array() is a string[].

Prerequisites: read Fields first, this page covers only the .array() modifier. The f proxy, the chain-modifier model, and the other shared modifiers (.required() / .default() / .localized()) are taught there.

There is no `f.array()` factory

.array() is a chain modifier, not a field type, you never call f.array(). You build the element field first, then wrap it: f.text(40).array(). It is one of the shared modifiers every field carries.

What it does

  • Wraps any inner field into a list, f.<type>(...).array() stores inner[], keeping the inner field's validation per element.
  • Stores as one jsonb column, the whole array lives in a single column, regardless of the inner type (no junction table, no separate rows).
  • Bounds the length, chain .minItems(n) / .maxItems(n) to constrain how many elements are allowed.
  • Filters by membership, the where clause exposes containsAll, containsAny, whole-array eq, length, and isEmpty / isNotEmpty instead of the scalar operators.
  • Powers multi-select, f.select([...]).array() is exactly how you get a multi-value select (see When to use it).
  • Renders a repeatable input in the admin form, a list of the inner field's component.

Quick start

Build the element field, then chain .array(). Bound the length with .minItems() / .maxItems() where it matters.

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

export const posts = collection("posts").fields(({ f }) => ({
  // string[], a list of free-text tags, capped at 10
  tags: f.text(40).array().maxItems(10),

  // number[], required, must contain at least one value
  ratings: f.number().array().minItems(1).required(),
}));

That gives posts a tags column typed string[] | null on read and a non-null ratings column typed number[], both stored as jsonb, both validated element-by-element and bounded by length, with no migration or schema written by hand. Run questpie generate then questpie push to register the change and create the column.

How .array() transforms the field

.array() rewrites four things on the field state.

AspectBefore .array()After .array()
Data typeT (the inner field's data)T[]
Columnthe inner column (e.g. varchar(40))jsonb (always)
Schemathe inner Zod schemaz.array(inner)
Operatorsthe inner set (e.g. stringOps)selectMultiOps (membership)
getType()the inner type ("text")"array"

The inner field is preserved on the state (innerField / innerState), so per-element validation still runs, f.text(40).array() rejects an element longer than 40 characters, and f.email().array() rejects a non-email element. The array itself is stored verbatim as a single JSONB value; there is no separate table or row-per-item.

Order doesn't matter for length, but `.minItems()` / `.maxItems()` only apply after `.array()`

.minItems(n) and .maxItems(n) are no-ops unless the field is an array, they refine the z.array(...) schema (arraySchema.min(n) / .max(n)). On a non-array field they set state that nothing reads.

Chained methods

.array() itself takes no arguments. The two modifiers that only make sense on an array are:

MethodEffectSource
.minItems(n)Array must have at least n elements (z.array(...).min(n)).field-class.ts:336
.maxItems(n)Array may have at most n elements (z.array(...).max(n)).field-class.ts:341

Everything else is a shared modifier and applies to the array as a whole:

  • .required() makes the column NOT NULL and the array required on insert (the element count is still governed by .minItems()).
  • .default([...]) seeds an initial array, type-checked against the element type, so f.number().array().default(["x"]) won't compile.
  • .label() / .description() set the admin label and helper text.
  • .localized() stores a per-locale array in the i18n side-table (a shared modifier).
collection("recipes").fields(({ f }) => ({
  // Up to 12 steps, defaults to an empty list, never null
  steps: f.text({ mode: "text" }).array().maxItems(12).default([]).required(),
}));

Filtering, operators

An array field uses the multi-value operator set (selectMultiOps). Every operand element is typed to the inner field's filter value, a number[] filters on number, a f.select([...]).array() filters on its literal union.

OperatorOperandMatches
containsAllItem[]Array contains every listed value (JSONB @>).
containsAnyItem[]Array contains at least one listed value (JSONB ?|).
eqItem[]Array equals the given array exactly (same elements, same order).
lengthnumberArray has exactly this many elements (jsonb_array_length).
isEmptybooleanArray is [] or null.
isNotEmptybooleanArray is non-empty and not null.
isNull / isNotNullbooleanColumn is (not) null. Pass true to assert, false to invert.
// Posts tagged with BOTH "ts" and "cms"
const { docs } = await app.collections.posts.find({
  where: { tags: { containsAll: ["ts", "cms"] } },
});

// Posts tagged with EITHER "ts" or "go"
const { docs: anyMatch } = await app.collections.posts.find({
  where: { tags: { containsAny: ["ts", "go"] } },
});

// Posts with no tags yet
const { docs: untagged } = await app.collections.posts.find({
  where: { tags: { isEmpty: true } },
});

`contains` (single item) is typed but not implemented

The generated where type lists a single-item contains: Item operator, but the runtime selectMultiOps set has no contains, only containsAll and containsAny. Using { tags: { contains: "ts" } } type-checks but is silently ignored at query time. Use containsAny: ["ts"] to match a single element.

For the full query language, combining field filters with AND / OR / NOT, pagination, and orderBy, see the query reference.

When to use it

  • .array(), any homogeneous list of one field type: tags (f.text().array()), scores (f.number().array()), checked options, a fixed set of attachments-by-id.
  • Multi-select, f.select([...]).array() is the canonical multi-value select. The inner select keeps its literal-union typing and option list; .array() makes it multiple. See f.select().
  • Bound the size, pair with .minItems() / .maxItems() whenever there's a real cap (a top-5 list, "at least one"), so the array is validated at the edge.
  • Reach for Relations instead when the elements are rows in another collection, a list of FKs you want to query, join, and hydrate with with:. .array() is for inline values, not normalized references.
  • Reach for f.object() (then .array()) when each element needs multiple sub-fields, f.object({ ... }).array() gives a list of structured items.

TypeScript

An array field contributes Item[] to the generated row and insert types, and an ArrayWhereInput shape to the where type. The element filter value is derived from the inner field, not the stored data, a f.datetime().array() filters on the inner field's date input, not Date[].

type Post = typeof posts.$infer.select;
//   ^? { id: string; tags: string[] | null; ratings: number[]; ... }
import type { CollectionDoc, CollectionWhere } from "#questpie";

type Post = CollectionDoc<"posts">;          // { tags: string[] | null; ratings: number[]; ... }
type PostFilter = CollectionWhere<"posts">;  // { tags?: { containsAll?: string[]; ... } }

.required() makes the array non-null and required on insert; without it the column is nullable (Item[] | null) and the insert field optional. .minItems() / .maxItems() add length validation to the derived z.array(...) schema but have no type-level effect. See Fields → inferred types for how modifiers flow into the generated types.

  • Fields, the f proxy, the chain-modifier model, and the other shared modifiers (.required(), .default(), .localized(), …).
  • f.select(), multi-select is f.select([...]).array().
  • f.object(), for a list of structured items, wrap an object: f.object({ ... }).array().
  • Relations, when the list elements are rows in another collection (and the full query language for the operators above).
  • Validation, the auto-derived z.array(...) schema and the .zod() escape hatch.

On this page