# Textarea field (/docs/schema/fields/textarea)

---
title: Textarea field
description: "`f.textarea()` is the multi-line string. There is no argument to pass, the `text` column is unbounded whatever you write, and the field filters with the full string operator set."
kind: reference
package: questpie
---

| Surface      | What `f.textarea()` produces                        |
| ------------ | --------------------------------------------------- |
| Column       | `text`, no length cap                               |
| Value        | `string`, or `string \| null` without `.required()` |
| Schema       | `z.string()`, no cap                                |
| Operators    | `stringOps`, fourteen of them                       |
| Form control | `TextareaField`, a plain textarea                   |
| List cell    | `TextCell`, truncated, off the default columns      |

Every call produces that same column. No method on the type reopens it, and only
`.array()` and the `.drizzle()` escape hatch replace it.

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

export const posts = collection("posts").fields(({ f }) => ({
	title: f.text(255).required(),
	excerpt: f.textarea(), // text, selects as string | null
	body: f.textarea().required().max(20_000), // text NOT NULL, capped in the schema
}));
```

## Methods

`.required()`, `.default()`, `.label()`, `.localized()` and the rest of the base
chain work here as on any field. On top of those the type adds two.

| Method    | Effect                                      |
| --------- | ------------------------------------------- |
| `.min(n)` | Adds `z.string().min(n)`, a character count |
| `.max(n)` | Adds `z.string().max(n)`, a character count |

Both write a validation bound and nothing else. The column stays unbounded
`text` either way, so a `.max(n)` you later raise needs no migration.

<Callout
	type="info"
	title="`.pattern()`, `.trim()`, `.lowercase()` and `.uppercase()` are not here"
>
	Only `min` and `max` come with the type. The other four belong to `f.text()`,
	and calling one here fails to compile. Normalize with `.zod()` or a
	`beforeValidate` hook instead.
</Callout>

## Filtering

Textarea carries `stringOps`, the same fourteen operators as
[`f.text()`](/docs/schema/fields/text).

| Operator               | Operand             | Matches                                     |
| ---------------------- | ------------------- | ------------------------------------------- |
| `eq` / `ne`            | `string`            | Equal, not equal                            |
| `not`                  | `string`, or `null` | Not equal, or `IS NOT NULL` when given null |
| `in` / `notIn`         | `string[]`          | In the list, not in the list                |
| `like` / `notLike`     | `string`            | `LIKE`, case-sensitive, you write the `%`   |
| `ilike` / `notIlike`   | `string`            | The same, case-insensitive                  |
| `contains`             | `string`            | Substring, the `%` are added for you        |
| `startsWith`           | `string`            | Prefix                                      |
| `endsWith`             | `string`            | Suffix                                      |
| `isNull` / `isNotNull` | `boolean`           | Pass `false` and each inverts               |

```ts
const { docs } = await app.collections.posts.find({
	where: { excerpt: { contains: "release" } },
});
```

<Callout type="warn" title="`contains` is a `LIKE` scan">
	It builds `%value%` around a bound parameter and scans. Fine for filtering a
	list, wrong for ranked search over prose. Use `.searchable()` and the search
	adapter for that.
</Callout>

## In the admin

The form control is `TextareaField`, a textarea at three rows. It sets
`resize-none`, so a reader cannot drag it taller, and it carries a minimum
height that a smaller `rows` will not go under. Raising `rows` is the one lever.

What crosses to the browser:

- `.max(n)` becomes the textarea's `maxLength` attribute, so typing stops there.
  Without it there is no attribute and no stop.
- The admin rebuilds `z.string()` from `minLength` and `maxLength`, so the form
  checks before it posts.
- Nothing else. The type has no pattern or format to send.

`.admin()` comes from `@questpie/admin` and takes five keys on this type, on top
of the shared ones every field has.

| Key           | Effect                                        |
| ------------- | --------------------------------------------- |
| `placeholder` | Placeholder text, resolved through the locale |
| `rows`        | The `rows` attribute, default 3               |
| `autoResize`  | Nothing reads it                              |
| `richText`    | Nothing reads it                              |
| `showCounter` | Nothing reads it                              |

The last three are declared on `TextareaFieldAdminMeta` and nothing under
`packages/` reads them. `autoResize` even reaches the control, which drops it.
Treat all three as absent.

### The list column

Textarea sits on the admin's heavy-field list, beside `json`, `object`,
`blocks` and `richText`, so it is never one of the default visible columns. The
column picker still offers it, and naming it in `.list()` pins it. When it does
render, `TextCell` truncates at 300 pixels and puts the whole value in the
cell's `title`.

```ts
.list(({ v, f }) => v.collectionTable({ columns: [f.excerpt] }))
```

<Callout type="warn" title="`showInList` will not bring it back">
	The key type-checks on every field's `.admin()`, and nothing under `packages/`
	reads it. Name the field in the `.list()` callback's `columns` instead.
</Callout>

## Lists

`.array()` replaces the column with `jsonb` and swaps the string operators for
the multi-value set. The admin renders one textarea per item.

```ts
notes: f.textarea().array().maxItems(20),
```

Per-item `.min()` and `.max()` still apply. `.minItems(n)` and `.maxItems(n)`
bound the list.

## Collaborative editing

`f.textarea()` is one of the two shapes `.crdt({ format: "text" })` accepts.

```ts
body: f.textarea().default("").required().crdt({ format: "text" }),
```

`.required()` and `.default("")` are both mandatory. `.min()`, `.max()` and
`.localized()` each disqualify the field, and QUESTPIE refuses to build the app
rather than merging into a value it cannot check.

## Related

[Text](/docs/schema/fields/text) is the single-line sibling.
`f.text({ mode: "text" })` produces the identical `text` column, schema and
operators. It differs in the type string, which routes the admin to a one-line
input instead of this control, and in carrying four more methods.

[Rich text](/docs/schema/fields/rich-text) is the step up when the prose needs
structure rather than a bigger box.

[Arrays](/docs/schema/fields/array) covers what `.array()` does to reads, writes
and filters.

[Eligible fields](/docs/schema/collaborative-documents/eligible-fields) lists
every reason a `.crdt()` field is rejected.

[Validation](/docs/schema/validation) covers the derived schema and `.zod()`,
the place to normalize a value.
