Number field
f.number() is a numeric field that maps to any Postgres number column, integer, smallint, bigint, real, double, or fixed-precision decimal, with value bounds, integer/step validation, and comparison operators built in.
f.number() stores a number. By default it produces an integer column, derives a z.number().int() schema, and exposes comparison operators (eq, gt, lte, in, …) on the typed where clause. Pass a mode to pick a different Postgres numeric type, smallint, bigint, real, double, or a fixed-precision decimal, and chain .min() / .max() / .positive() / .step() to bound and validate the value.
Prerequisites: read Fields first, this page covers only the
numbertype. Thefproxy, the chain-modifier model, and shared modifiers like.required()/.default()/.localized()are taught there.
What it does
- Stores a number in any Postgres numeric type,
integerby default; switch tosmallint,bigint,real,double, or fixed-precisiondecimalwith one argument. - Reads back as a JS
number, every mode (includingbigintanddecimal) uses Drizzle'smode: "number", so you never deal withbigint/string values on read. - Validates the value, integer modes auto-enforce
z.number().int(); add.min(n)/.max(n)for range bounds,.positive()for> 0, and.step(n)for a "multiple of n" check. Unlike text's no-op modifiers, every number method is wired into the schema. - Filters with comparison operators,
eq,ne,gt,gte,lt,lte,in,notIn,isNull,isNotNull, all typed againstnumber. - Renders a number input in the admin form.
Quick start
Use f.number() inside a .fields() callback. The optional argument selects the storage mode; chain modifiers to bound and validate the value.
import { collection } from "#questpie/factories";
export const products = collection("products").fields(({ f }) => ({
stock: f.number().required(), // integer, NOT NULL
rating: f.number("smallint").min(1).max(5), // smallint, 1..5
price: f.number({ mode: "decimal", precision: 10, scale: 2 }), // numeric(10,2)
}));That gives products a stock column typed number on read, required on insert, validated as an integer, and filterable with the comparison operators below, no schema or migration written by hand.
Constructor argument
f.number() has three call signatures. The argument selects the storage mode (it is positional, there is no general options object).
| Call | Column | Read type | Notes |
|---|---|---|---|
f.number() | integer | number | Default. Mode is "integer"; schema is z.number().int(). |
f.number("smallint") | smallint | number | 16-bit integer; also z.number().int(). |
f.number("bigint") | bigint (mode: "number") | number | 64-bit; reads as JS number, not bigint. |
f.number("real") | real | number | 32-bit float. No .int(). |
f.number("double") | doublePrecision | number | 64-bit float. No .int(). |
f.number({ mode: "decimal", precision?, scale? }) | numeric(precision, scale) (mode: "number") | number | Fixed precision. Defaults: precision: 10, scale: 2. Reads as JS number. |
The resulting field data is always number. The derived Zod schema is z.number(), with .int() added automatically for the integer and smallint modes (isInt flag). The float modes (real, double) and decimal accept fractional values.
`bigint` and `decimal` read back as JS `number`
Both use Drizzle's mode: "number", so the value type on read/insert/update is a plain JS number. That keeps the API uniform, but a number only holds integers exactly up to Number.MAX_SAFE_INTEGER (2^53 − 1) and decimal arithmetic goes through floating point. For money or IDs beyond that range, store an exact string value with f.text() and validate the format with .zod(), or create a dedicated custom field type.
Chained methods
These methods are specific to number (on top of the shared modifiers every field has). Each returns a new immutable field, so they chain in any order.
| Method | Effect | Schema |
|---|---|---|
.min(n) | Minimum value (inclusive). | z.number().min(n) |
.max(n) | Maximum value (inclusive). | z.number().max(n) |
.positive() | Value must be > 0. | z.number().positive() |
.int() | Value must be an integer (already implied by integer/smallint modes). | z.number().int() |
.step(n) | Value must be a multiple of n, adds a value % n === 0 refinement. | .refine(v => v % n === 0) |
collection("orders").fields(({ f }) => ({
quantity: f.number().required().min(1).step(1), // ≥ 1, whole units
discount: f.number("real").min(0).max(100), // percentage 0..100
}));All five refinements are applied to the derived Zod schema in applyRefinements (packages/questpie/src/server/fields/derive-schema.ts:136). They run on create and update.
`.min()` / `.max()` here are VALUE bounds, not string length
On a number field, .min(n) / .max(n) constrain the numeric value. On f.text() the same method names constrain string length (they map to minLength / maxLength). Same names, different meaning, sized by the field's type.
Filtering, operators
number uses the number operator set (numberOps), so every number field is filterable with these in a where clause. The operand is typed number (or number[] for in / notIn).
| Operator | Operand | Matches |
|---|---|---|
eq / ne | number | Exact equal / not equal. |
gt / gte | number | Greater than / greater-or-equal. |
lt / lte | number | Less than / less-or-equal. |
in / notIn | number[] | Value is (not) in the list. |
isNull / isNotNull | boolean | Column is (not) null. |
// In-stock products priced between 10 and 50:
const { docs } = await app.collections.products.find({
where: {
stock: { gt: 0 },
price: { gte: 10, lte: 50 },
},
});For the full query language, combining field filters with AND / OR / NOT, pagination, and orderBy, see the query reference.
When to use it
f.number()(integer), counts, quantities, view counters, ages, anything whole. The default mode is right for the vast majority of cases.f.number("smallint"), small bounded integers (a 1-5 rating, a day of week) where the 16-bit range is plenty.f.number("bigint"), large whole numbers (byte sizes, large counters) that exceed the 32-bitintegerrange but stay within JS safe-integer range.f.number("real")/f.number("double"), floating-point measurements where small rounding is acceptable: weights, sensor readings, percentages.f.number({ mode: "decimal" }), fixed-precision values like prices and money, where you controlprecision/scale. (Mind the JS-numberrounding note above for high-stakes math.)
Multiple values
Chain .array() to store a list of numbers in a single jsonb column, and bound the list length with .minItems(n) / .maxItems(n). This is a shared modifier, not number-specific:
scores: f.number().array().maxItems(10), // number[] stored as jsonbTypeScript
A number field contributes a number to the generated row, insert, and where types, no annotation needed. After questpie generate, pull the shapes off the collection or the app types:
type Product = typeof products.$infer.select;
// ^? { id: string; stock: number; rating: number | null; price: number | null; ... }import type { CollectionDoc, CollectionWhere } from "#questpie/types";
type Product = CollectionDoc<"products">; // { stock: number; ... }
type ProductFilter = CollectionWhere<"products">; // { stock?: { gt?: number; ... } }.required() makes the field non-null and required on insert; without it the column is nullable and the insert field optional. .default(0) makes the input optional and is type-checked against number. See Fields → inferred types for how modifiers flow into the generated types.
Related
- Fields, the
fproxy, the chain-modifier model, and the shared modifiers (.required(),.default(),.array(),.localized(), …). f.text(), strings, where.min()/.max()mean length instead of value.- Validation, the auto-derived Zod schema and the
.zod()escape hatch for custom numeric rules. - Relations, the full query language for combining the operators above.