Email field
`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.
| 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.
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().
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 |
email: f.email(320).required().min(6),`.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.
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.
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 |
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" }.
`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.
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
maxLengthattribute 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, anddomainanddomainInare 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.
recipients: f.email().array().maxItems(20),Arrays 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.
type User = typeof users.$infer.select;
// ^? { id: string; email: string; backupEmail: string | null; ... }Related
Fields is the table of every type and the modifiers they all share.
f.text() is the same varchar without the format
check, and it adds .pattern() for a rule of your own.
f.url() is the other validated string, extending
the same string set with host and protocol operators.
Validation covers the derived schema and .zod(),
the way to normalize a value.
Reading and writing covers the rest of the
query surface around where.
Textarea field
`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.
URL field
`f.url()` is a varchar whose value has to parse as a URL. The call picks the width, the width becomes the Zod cap, and the field filters with the string set plus host and protocol.