QUESTPIE
SchemaFields

Number field

One factory over six Postgres numeric types. The mode you pass picks the column and decides whether the derived schema demands a whole number.

View markdown

Signature

CallColumnDerived schema
f.number(), f.number("integer")integerz.number().int()
f.number("smallint")smallintz.number().int()
f.number("bigint")bigintz.number()
f.number("real")realz.number()
f.number("double")double precisionz.number()
f.number({ mode: "decimal", precision?, scale? })numeric(precision, scale)z.number()

precision and scale fall back separately, to 10 and to 2, so f.number({ mode: "decimal", precision: 18 }) gives you numeric(18, 2).

.int() reaches the schema for integer and smallint only. bigint is not in that pair, so a fractional value passes validation there. Chain .int() yourself on any mode where you want whole numbers.

Every mode reads and writes a plain JS number. bigint and decimal are the two Drizzle column builders that could hand you something else, so QUESTPIE passes them mode: "number". The other four take no such option.

src/questpie/server/collections/products.ts
import { collection } from "#questpie/factories";

export const products = collection("products").fields(({ f }) => ({
	stock: f.number().required(),
	rating: f.number("smallint").min(1).max(5),
	price: f.number({ mode: "decimal", precision: 10, scale: 2 }),
}));

`bigint` mode is a 53-bit number

Drizzle maps it to its int53 column builder, so a value past Number.MAX_SAFE_INTEGER loses precision on read. decimal comes back through Number() too, so arithmetic on it is floating point.

Value refinements

Five methods on top of the modifiers every field has. Each returns a new field, so they chain in any order.

MethodSchemaAdmin
.min(n).min(n), inclusivethe input's min
.max(n).max(n), inclusivethe input's max
.positive().positive(), strictly above zeronothing
.int().int()nothing
.step(n)refines value % n === 0nothing

All five apply to the derived schema on create and update. Only .min() and .max() carry on to the admin control.

collection("orders").fields(({ f }) => ({
	quantity: f.number().required().min(1),
	discount: f.number("real").min(0).max(100),
}));

`.min()` and `.max()` bound the value here

The same two names on f.text() bound string length instead. One pair of names, two pieces of state, picked by the type of field you put them on.

`.step()` is a JS remainder

The check is value % n === 0, so a fractional step inherits float error. .step(0.1) rejects 0.3, because 0.3 % 0.1 is 0.09999999999999998. Whole steps are exact.

Filtering

A number field carries the number operator set, so the operator object in its where entry types to exactly these eleven keys.

OperatorOperandMatches
eq, nenumberEqual, not equal
notnumber | nullNot equal, or IS NOT NULL when you pass null
gt, gtenumberAbove, at or above
lt, ltenumberBelow, at or below
in, notInnumber[]In the list, not in the list
isNull, isNotNullbooleanNull check, inverted when you pass false

A bare value is the shorthand and types alongside that object. { stock: 5 } compiles to eq, { stock: null } to IS NULL.

const { docs } = await app.collections.products.find({
	where: {
		stock: { gt: 0 },
		price: { gte: 10, lte: 50 },
	},
});

In the admin

The control is an <input type="number">. List cells print through toLocaleString() in tabular figures, and a null shows as -.

.min() and .max() arrive as the input's own bounds, and the control clamps what you type back into range before the form sees it.

.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.

KeyTypeDefaultEffect
placeholderstringnonePlaceholder text
showButtonsbooleanfalseWraps the input in minus and plus buttons
stepnumber1The input's step, and how far a button moves

.step(n) on the field is validation only and never reaches the control, so set step here as well when you want the two to agree.

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

Lists of numbers

.array() turns the column into jsonb and swaps the operator set. Every comparison above goes, gt and lte and in with them, and the eq that survives takes the whole list. .minItems(n) and .maxItems(n) bound it. The admin drops the number control for a numbered list of plain number inputs, so the clamping and the buttons go too.

scores: f.number().array().maxItems(10),

Arrays has the operator table that replaces this one.

Types

A number field contributes number to the row, the insert and the where. .required() makes it non-null and mandatory on insert. .default(0) makes the input optional and type-checks the literal against number.

type Product = typeof products.$infer.select;
//   ^? { id: string; stock: number; rating: number | null; ... }
import type { CollectionDoc, CollectionWhere } from "#questpie";

type Product = CollectionDoc<"products">;
type ProductFilter = CollectionWhere<"products">;

Fields is the table of every type and the modifiers they all share.

f.text() is the string field, where .min() and .max() mean length.

Arrays covers .array() on any field.

Validation covers the derived schema and the .zod() escape hatch.

Reading and writing covers the rest of the query surface around where.

On this page