# Number field (/docs/schema/fields/number)

---
title: Number field
description: One factory over six Postgres numeric types. The mode you pass picks the column and decides whether the derived schema demands a whole number.
kind: reference
package: questpie
---

## Signature

| Call                                                | Column                      | Derived schema     |
| --------------------------------------------------- | --------------------------- | ------------------ |
| `f.number()`, `f.number("integer")`                 | `integer`                   | `z.number().int()` |
| `f.number("smallint")`                              | `smallint`                  | `z.number().int()` |
| `f.number("bigint")`                                | `bigint`                    | `z.number()`       |
| `f.number("real")`                                  | `real`                      | `z.number()`       |
| `f.number("double")`                                | `double precision`          | `z.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.

```ts title="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 }),
}));
```

<Callout type="warn" title="`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.
</Callout>

## Value refinements

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

| Method        | Schema                             | Admin             |
| ------------- | ---------------------------------- | ----------------- |
| `.min(n)`     | `.min(n)`, inclusive               | the input's `min` |
| `.max(n)`     | `.max(n)`, inclusive               | the input's `max` |
| `.positive()` | `.positive()`, strictly above zero | nothing           |
| `.int()`      | `.int()`                           | nothing           |
| `.step(n)`    | refines `value % n === 0`          | nothing           |

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

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

<Callout type="info" title="`.min()` and `.max()` bound the value here">
	The same two names on [`f.text()`](/docs/schema/fields/text) bound string
	length instead. One pair of names, two pieces of state, picked by the type of
	field you put them on.
</Callout>

<Callout type="warn" title="`.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.
</Callout>

## Filtering

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

| Operator              | Operand          | Matches                                          |
| --------------------- | ---------------- | ------------------------------------------------ |
| `eq`, `ne`            | `number`         | Equal, not equal                                 |
| `not`                 | `number \| null` | Not equal, or `IS NOT NULL` when you pass `null` |
| `gt`, `gte`           | `number`         | Above, at or above                               |
| `lt`, `lte`           | `number`         | Below, at or below                               |
| `in`, `notIn`         | `number[]`       | In the list, not in the list                     |
| `isNull`, `isNotNull` | `boolean`        | Null 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`.

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

| Key           | Type      | Default | Effect                                         |
| ------------- | --------- | ------- | ---------------------------------------------- |
| `placeholder` | `string`  | none    | Placeholder text                               |
| `showButtons` | `boolean` | `false` | Wraps the input in minus and plus buttons      |
| `step`        | `number`  | `1`     | The 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.

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

[Arrays](/docs/schema/fields/array) 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`.

```ts
type Product = typeof products.$infer.select;
//   ^? { id: string; stock: number; rating: number | null; ... }
```

```ts
import type { CollectionDoc, CollectionWhere } from "#questpie";

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

## 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 string field, where `.min()` and
`.max()` mean length.

[Arrays](/docs/schema/fields/array) covers `.array()` on any field.

[Validation](/docs/schema/validation) covers the derived schema and the `.zod()`
escape hatch.

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