# Email field (/docs/schema/fields/email)

---
title: Email field
description: "`f.email()` is a varchar with a format check on it. One argument sizes the column, and the type extends the string operator set with two domain matchers."
kind: reference
package: questpie
---

| Call         | Column         | Base schema                   |
| ------------ | -------------- | ----------------------------- |
| `f.email()`  | `varchar(255)` | `z.string().email().max(255)` |
| `f.email(n)` | `varchar(n)`   | `z.string().email().max(n)`   |

One optional argument, a character count. There is no object form and no mode.
That argument is the only thing that moves the column, and no method on the type
reopens it. Only `.array()` and the `.drizzle()` escape hatch replace the column.

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

export const users = collection("users").fields(({ f }) => ({
	email: f.email().required(), // varchar(255), NOT NULL
	backupEmail: f.email(320), // varchar(320), nullable
}));
```

Refinements and nullability layer onto that base schema. Without `.required()`
the field selects as `string | null` and the schema accepts null on write. With
it, `string`. Nothing here makes the address unique. That is a `uniqueIndex` on
the column, through [`.indexes()`](/docs/schema/collections).

## Methods

Two methods come with the type. `.required()`, `.default()`, `.label()`,
`.localized()` and the rest of the base chain work here as on any field.

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

```ts
email: f.email(320).required().min(6),
```

<Callout type="warn" title="`.max()` tightens, it never widens">
	The constructor bakes its width into the schema and `.max(n)` adds a second
	check beside it. `f.email().max(500)` still rejects 300 characters, and the
	column stays `varchar(255)`. Widen by changing the constructor argument.
</Callout>

<Callout type="warn" title="A well-formed address is not a reachable one">
	The check is syntax. Nothing resolves the domain or asks whether the mailbox
	accepts mail. There is no normalization either, so `Ada@Example.com` stores
	with its capitals. Lowercase it in a `beforeValidate` hook, which runs before
	the schema.
</Callout>

## Filtering

Email carries `emailOps`, the fourteen string operators plus two of its own.

| 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               |
| `domain`               | `string`            | The part after `@`, case-insensitive        |
| `domainIn`             | `string[]`          | That part is any one of these               |

```ts
const { docs } = await app.collections.users.find({
	where: { email: { domain: "acme.com" } },
});
```

A bare value is the shorthand and types alongside the operator object.
`{ email: "ada@acme.com" }` compiles to equality.

`eq`, `in`, `contains` and the `like` pair compare case-sensitively, because
Postgres does on a `varchar`. The `ilike` pair and both domain operators do not.
So a stored `Ada@Acme.com` misses `{ eq: "ada@acme.com" }` and still answers
`{ domain: "acme.com" }`.

<Callout type="info" title="`domain` matches the whole suffix">
	It compiles to `ILIKE '%@value'`, so `{ domain: "acme.com" }` finds
	`a@acme.com` and misses `a@mail.acme.com`. An empty `domainIn` list compiles
	to `FALSE`, which matches no row rather than every row.
</Callout>

## In the admin

The form control is `EmailField`, an `<input type="email">` carrying
`autocomplete="email"` and `inputmode="email"`, so touch keyboards surface the
`@` key. The list cell prints the address as stored, and a null or empty value
shows as `-`.

What crosses to the browser:

- The admin rebuilds a Zod check from the metadata, a valid address capped at
  `maxLength`, so the form checks before it posts.
- No `maxLength` attribute reaches the input. `f.text()` sends one and typing
  stops at the width, email does not.
- The filter builder hands email the text operator list, eight entries:
  `contains`, `not_contains`, `equals`, `not_equals`, `starts_with`,
  `ends_with`, `is_empty`, `is_not_empty`. Prefix and suffix have no negated
  form there, and `domain` and `domainIn` are API only.

`.admin(config)` sets the rest of the control. It appears on the field once
`@questpie/admin` is enabled, and its generated signature takes `unknown`, so
nothing type-checks the keys you pass.

| Key           | Type     | Effect                       |
| ------------- | -------- | ---------------------------- |
| `placeholder` | `string` | Placeholder text             |
| `domainHint`  | `string` | Nothing. Declared and unread |

Keys such as `showInList`, `listWidth`, `sortable` and `filterable` come from
the base admin config that every field type shares.

## Lists of addresses

`.array()` replaces the column with `jsonb` and swaps the operator set for the
multi-value one. `domain`, `domainIn` and every string operator go. Three
survive the swap: `isNull`, `isNotNull`, and `eq`, which now takes a `string[]`
and compares the whole list at once. The per-item format check and length cap
survive too, and `.minItems(n)` / `.maxItems(n)` bound the list. The admin drops
the email control for a numbered list of email inputs.

```ts
recipients: f.email().array().maxItems(20),
```

[Arrays](/docs/schema/fields/array) has the operator table that replaces this
one.

## Types

An email field contributes `string` to the row, the insert and the `where`.
`.required()` makes it non-null and mandatory on insert.

```ts
type User = typeof users.$infer.select;
//   ^? { id: string; email: string; backupEmail: string | null; ... }
```

## Related

[Fields](/docs/schema/fields) is the table of every type and the modifiers they
all share.

[`f.text()`](/docs/schema/fields/text) is the same varchar without the format
check, and it adds `.pattern()` for a rule of your own.

[`f.url()`](/docs/schema/fields/url) is the other validated string, extending
the same string set with host and protocol operators.

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

[Reading and writing](/docs/schema/collections/crud) covers the rest of the
query surface around `where`.
