# Select field (/docs/schema/fields/select)

---
title: Select field
description: f.select() takes a list of options, or a handler that produces one. A static list narrows the read type to the literal union of its values and derives a z.enum from them.
kind: reference
package: questpie
---

| Call                              | Column                                         | Read type                | Schema           |
| --------------------------------- | ---------------------------------------------- | ------------------------ | ---------------- |
| `f.select([{ value, label }, …])` | `varchar(n)`, `n` is the longest value, min 50 | union of the values      | `z.enum(values)` |
| `f.select([])`                    | `varchar(255)`                                 | `string`                 | `z.string()`     |
| `f.select({ handler, deps })`     | `varchar(255)`                                 | `string`                 | `z.string()`     |
| `.enum(name)` on a static list    | the Postgres enum type `name`                  | unchanged                | unchanged        |
| `.array()`                        | `jsonb`                                        | a list of the same value | `z.array(…)`     |

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

export const posts = collection("posts").fields(({ f }) => ({
	status: f
		.select([
			{ value: "draft", label: "Draft" },
			{ value: "published", label: "Published" },
		])
		.required()
		.default("draft"),
}));
```

`posts.status` reads back as `"draft" | "published"`, and `.default("archived")`
does not compile. Drop the `.required()` and the read type picks up `null`. The
column is `varchar(50)`, because 50 is the floor and no value is longer.

## The option object

`value` and `label` are required. The other four shape the admin, and QUESTPIE
serializes all six into the introspection payload.

| Key           | Type                 | What reads it                                       |
| ------------- | -------------------- | --------------------------------------------------- |
| `value`       | `string \| number`   | The stored value. A number is written as its string |
| `label`       | `I18nText`           | The dropdown row and the list cell                  |
| `description` | `I18nText`           | A second line under the label in the dropdown       |
| `disabled`    | `boolean`            | The dropdown row, which stops responding            |
| `icon`        | `ComponentReference` | Leads the dropdown row and the list cell            |
| `className`   | `string`             | Classes on the dropdown row and on the list cell    |

`I18nText` is a plain string, a locale map such as `{ en: "Ready" }`, or a
`{ key }` reference your i18n adapter resolves. Icons come from the `c` proxy
that arrives beside `f`.

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

export const deployments = collection("deployments").fields(({ f, c }) => ({
	health: f.select([
		{
			value: "ready",
			label: { en: "Ready", sk: "Pripravené" },
			description: "Runtime is healthy",
			icon: c.icon("ph:check-circle"),
			className: "border-emerald-500/30 bg-emerald-500/10",
		},
		{ value: "degraded", label: "Degraded", icon: c.icon("ph:warning") },
	]),
}));
```

<Callout type="info" title="The union needs the values in the call">
	Narrowing comes from a `const` type parameter, so the literals have to be
	visible where you call `f.select()`. Inline options narrow. A hoisted array
	needs `as const` or the read type falls back to `string`.
</Callout>

## `.enum(name)`

The one method `select` adds. It builds a fresh `pgEnum(name, values)` from the
static values and swaps the column factory to it. Nothing else moves, not the
read type, not the derived schema, not the operators.

```ts
status: f
	.select([
		{ value: "draft", label: "Draft" },
		{ value: "published", label: "Published" },
	])
	.enum("post_status"),
```

<Callout type="warn" title="`.enum()` can end up declaring nothing">
	On `f.select([])` or a handler there are no values, so the call is a silent
	no-op and the column stays `varchar(255)`. Even on a static list,
	`app.getSchema()` hands `questpie push` the tables only, and the `pgEnum`
	stays closed over inside the column factory. Read the generated SQL.
</Callout>

## Filtering

A select carries the single-value operator set. The typed `where` map is exactly
these six.

| Operator               | Operand          | SQL                                            |
| ---------------------- | ---------------- | ---------------------------------------------- |
| `eq` / `ne`            | one value        | `=` / `<>`                                     |
| `in` / `notIn`         | a list of values | `IN` / `NOT IN`                                |
| `isNull` / `isNotNull` | `boolean`        | `IS NULL` / `IS NOT NULL`, inverted by `false` |

```ts
const { docs } = await app.collections.posts.find({
	where: { status: { ne: "draft" } },
});
```

## `.array()`

`.array()` is the shared modifier, not something `select` defines. On a select it
turns the column into `jsonb`, wraps the schema as `z.array(z.enum(values))`, and
takes `.minItems()` and `.maxItems()`. The admin renders one dropdown per row.

```ts
tags: f
	.select([
		{ value: "news", label: "News" },
		{ value: "guide", label: "Guide" },
	])
	.array()
	.maxItems(5),
```

<Callout type="warn" title="An arrayed select keeps the single-value `where`">
	`.array()` swaps the runtime set for `selectMultiOps`, but the select carries
	its own `whereInput` through the wrapping. So the typed map stays `eq` / `ne`
	/ `in` / `notIn` on one value, `eq` runs against the whole array, and the
	membership operators are unreachable from the typed clause.
</Callout>

## A handler instead of a list

Pass `{ handler, deps }` when the choices are not known at build time. The field
then stores a plain `string`, and introspection ships an empty option list plus
the form paths to watch.

| Key       | Type                                   | Default                       |
| --------- | -------------------------------------- | ----------------------------- |
| `handler` | `(ctx) => OptionsResult \| Promise<…>` | Required                      |
| `deps`    | `string[]` or `(ctx) => any[]`         | None, the watch list is empty |

Name the fields you read. Introspection tracks a `deps` function, never the
handler, so omitting `deps` ships an empty watch list and nothing refetches as
the form changes.

The handler receives `{ data, sibling, search, page, limit, ctx }`. `data` is the
current form, `page` counts from 0, `limit` defaults to 20 and caps at 100, and
`ctx` is `{ db, user, locale }` plus an optional `req`, with `db` typed
`unknown`. Return `{ options, hasMore?, total? }`. Each option is a `value` and a
`label`, and that pair is all that crosses the wire.

```ts
city: f.select({
	handler: async ({ data }) =>
		data.country === "us"
			? { options: [{ value: "nyc", label: "New York" }] }
			: { options: [] },
	deps: ["country"],
}),
```

<Callout type="warn" title="Fetching them takes one hook">
	The admin serves these options over its own route, and `useFieldOptions` from
	`@questpie/admin/client` calls it with the form data, search and page. Wire
	that hook into your own field component. The built-in control renders the
	options it was handed, which for a handler is none.
</Callout>

## In the admin

The form renders a dropdown, with a search box once the list passes eight
options. The list cell renders a badge carrying the option's icon, label and
`className`. Four keys sit on `.admin()` for a select, on top of the ones every
field shares. Only the first reaches the control.

| Key          | Type                                               | Effect                                    |
| ------------ | -------------------------------------------------- | ----------------------------------------- |
| `clearable`  | `boolean`, default `true`                          | The clear button, and Backspace or Delete |
| `displayAs`  | `"dropdown" \| "radio" \| "checkbox" \| "buttons"` | Accepted by the type, read by nothing     |
| `searchable` | `boolean`                                          | Accepted by the type, read by nothing     |
| `creatable`  | `boolean`                                          | Accepted by the type, read by nothing     |

## Related

[Array](/docs/schema/fields/array) is the modifier itself, on every type rather
than this one.

[Relations](/docs/schema/relations) is what you want when the choices are rows in
another table, with a real foreign key.

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

[Reading and writing](/docs/schema/collections/crud) covers the rest of the query
language around the operators above.
