Decimal
Exact fixed-decimal values with one portable precision and scale
Basic usage
import { Decimal, s } from "viborm";
const amount = s.decimal({ precision: 10, scale: 2 });
const optionalAmount = amount.nullable();
const amountWithDefault = amount.default(new Decimal("0"));
precision is the maximum total digit count. scale is the maximum
fractional digit count and must be between zero and precision. A value that
does not fit is rejected; assignment never rounds it to fit.
The descriptor is required because it is the one portable definition of the
field. PostgreSQL and MySQL record it in the native column type. SQLite cannot
recover it from a DECIMAL(...) declaration, so VibORM stores an integer
coefficient and uses the same descriptor to scale it.
There is no zero-argument decimal factory, .fixed() mode, rounding option, or
decimal native-type override.
Inputs and results
Writes accept Decimal | string | number. They do not accept bigint.
Selected values are fresh Decimal instances:
await db.invoice.create({
data: { total: "1234.56" },
});
const invoice = await db.invoice.findFirstOrThrow();
invoice.total.eq("1234.56"); // true
invoice.total.plus("0.44"); // Decimal(1235)
invoice.total.toNumber(); // explicit, potentially lossy conversion
Strings use plain decimal notation: an optional sign, digits, and at most one
decimal point. Whitespace, exponent notation, NaN, and infinity are refused.
A Decimal constructed from exponent notation is accepted when its expanded
exact value fits the descriptor.
"1234.56"; // accepted for { precision: 10, scale: 2 }
"+001.20"; // accepted and canonicalized privately to "1.2"
"1e3"; // rejected as a string
new Decimal("1e3"); // accepted when 1000 fits
A JavaScript number is only a convenience. VibORM validates String(number);
it cannot recover digits already lost to binary floating point and never
rounds the number into the field:
await db.invoice.create({
data: { total: 0.1 + 0.2 },
});
// Rejected by a scale-2 field: the value supplied is
// "0.30000000000000004", not exact 0.30.
Keep audited input as a string or Decimal end to end.
Equality in tests
Compare decimal values with .eq(), .cmp(), or another Decimal.js comparison
method. Do not use ===: every selected scalar and list member is a fresh value.
VibORM constructs results with the one Decimal constructor exported from
viborm. A returned value therefore satisfies both instanceof Decimal and
value.constructor === Decimal. Result construction temporarily isolates only
the constructor’s exponent range, so a narrow application minE or maxE
cannot turn a stored exact value into zero or infinity. The previous range is
restored before the value is returned. Application arithmetic then uses the
complete current Decimal configuration, while built-in field-domain
validation, SQL, storage, cache identity, migrations, result decoding, and
database rounding remain configuration-independent.
Use Decimal.js comparison methods for the semantic assertion:
expect(row.total.eq(new Decimal("1.2"))).toBe(true);
expect(row.total.constructor).toBe(Decimal);
Decimal#toJSON() returns a string for ordinary application JSON. VibORM does
not depend on JSON or structured cloning to preserve a Decimal prototype.
Custom validation
.schema() observes a Decimal, not the private canonical text. It may refine
that value or return another complete, bounded, finite Decimal.js numerical
representation. Decimal.js has no unforgeable constructor-history witness, so
VibORM validates and snapshots the observable numerical representation instead
of claiming historical provenance. The field descriptor is validated last, so
a custom schema cannot return a string, number, tag-only, incomplete, or
malformed representation, NaN, infinity, or a value outside the field domain.
import { Decimal, s } from "viborm";
import { custom } from "valibot";
const positiveAmount = s
.decimal({ precision: 10, scale: 2 })
.schema(custom<Decimal>((value) => value.isPositive()));
A literal .default(...) runs this validation and transformation once when the
field is declared. An ORM create that omits the field reuses that canonical
output, so it agrees with a database-defaulted raw insert and does not run the
custom schema again. An explicit write runs the schema once. A function default
runs it once after each invocation.
Physical storage and provider limits
| Provider | Scalar storage | Bind-time descriptor limit for scalars and lists |
|---|---|---|
| PostgreSQL | NUMERIC(precision,scale) |
precision <= 1000 |
| MySQL / PlanetScale | DECIMAL(precision,scale) |
precision <= 65, scale <= 30, and precision + scale <= 65 |
| SQLite3 / Bun SQLite / libSQL / D1 | checked scaled INTEGER coefficient |
precision <= 18 and precision + scale <= 18 |
For { precision: 10, scale: 2 }, logical 12.34 is coefficient 1234 on
SQLite. The column check requires integer storage and enforces the descriptor’s
range. VibORM does not use SQLite REAL, NUMERIC affinity, or decimal text as a
repair for missing native decimal support.
A descriptor can be valid in a model yet too wide for a selected provider. Client construction rejects that binding before provider I/O and names the field, descriptor, provider, and limit.
Every effectful MySQL migration command proves MySQL 8.0.16 or later and a
strict SQL mode (STRICT_TRANS_TABLES or STRICT_ALL_TABLES) once on its
pinned session before its first effect. The proof is command-wide because
provider-authored DDL and manual artifacts cannot be classified safely by their
decimal effects. Earlier MySQL releases parse but do not enforce the named
CHECK constraints used to prove decimal conversions.
Ordinary typed writes do not add a session-admission query. Arithmetic remains
fail-loud on non-strict sessions because the same UPDATE proves the exact
intermediate and final domains before assignment; an unsafe result takes an
error arm and leaves the row unchanged. Configure strict mode for raw SQL and
other out-of-band writes.
Filters, ordering, updates, and aggregates
Every supported provider answers decimal equality, ordered comparison,
ordering, cursors, pagination, distinct, grouping, aggregates, and atomic
arithmetic exactly. SQLite compares the stored coefficients as integers; it
does not cast them through REAL.
Field references are accepted only when both decimal fields have the same precision and scale. A generic typed SQL fragment has no trusted descriptor and is therefore refused as a decimal predicate operand; use a field reference or raw SQL.
An update is either a bare value or exactly one operation:
await db.account.update({
where: { id },
data: { balance: { increment: "0.20" } },
});
// Also: set, decrement, multiply, divide.
Empty operation objects, multiple operation keys, unknown or inherited keys,
and explicit undefined are validation errors. Every operand must fit the
field descriptor. multiply, divide, and _avg round only the derived result
with round-half-to-even. Create, set, filters, increment, and decrement never
round input. Division by zero is rejected before I/O.
_min, _max, and _avg stay in the field domain. _sum preserves the field
scale but can exceed its storage precision; all four return Decimal | null.
Decimal lists
const samples = s.decimal({ precision: 16, scale: 2 }).array();
| Provider | List storage |
|---|---|
| PostgreSQL | NUMERIC(precision,scale)[] |
| MySQL / PlanetScale | JSON array of coefficient strings |
| SQLite family | TEXT containing a JSON array of coefficient strings |
At scale 2, logical ["1.2", "-0.03"] is stored as
["120", "-3"] on JSON-backed providers. Members are strings, never JSON
numbers, so D1 and JavaScript cannot round a coefficient above 2^53.
Literal list defaults are database defaults as well as application defaults:
const samples = s
.decimal({ precision: 16, scale: 2 })
.array()
.default(["1.20", "-3.40"]);
An insert that omits samples, including raw SQL, receives that list.
PostgreSQL stores the native default as {1.20,-3.40}. MySQL, PlanetScale,
SQLite, libSQL, and D1 store ["120","-340"], with coefficient strings rather
than JSON numbers. Empty-list defaults work on every provider. A function
default remains application-only because it has no database expression;
default(null) keeps its existing SQL NULL behavior.
Typed reads return Decimal[]. Lists support whole-list equality, not,
has, hasEvery, hasSome, isEmpty, whole-list set, and push/unshift.
Operands are logical values: has: "1.2" means 1.2, not coefficient 120.
Whole-list equality preserves order and multiplicity; containment uses member
semantics.
Decimal lists have no numeric ordering, numeric aggregates, or arithmetic.
They cannot be IDs, unique fields, index or compound-key members, foreign-key
members, or relation identity members. _count.field counts non-null lists,
not their elements.
Cache and application arithmetic
Cache snapshots, operation identity, cursors, and relation keys use canonical
private strings. Every cache hit reconstructs fresh public Decimal instances;
the cache never clones a Decimal prototype.
Decimal.js configuration can affect custom .schema() code and arithmetic your
application performs on returned values. It cannot change VibORM’s built-in
field-domain validation, SQL, storage, cache identity, migrations, result
decoding, or half-even database arithmetic.
Descriptor migrations
VibORM can widen precision, narrow it when all values fit, increase scale exactly, and decrease scale only when every discarded digit is zero. Scalar and list conversions preserve order and duplicates and never round existing data. Failed conversions preserve the last provider-guaranteed estate.
Live descriptor changes follow the Migration V1 provider-admission matrix:
- MySQL list metadata lives in an exact VibORM column-comment marker. A
same-scale precision widening leaves every member string unchanged, but still
runs
ADD CHECK,MODIFY COLUMNto replace the marker, thenDROP CHECK. Narrowing and every list conversion that must rewrite members are refused before effects because MySQL DDL implicitly commits and a columnCHECKcannot quantify over JSON members. A generated widening is marked irreversible instead of emitting that unsafe narrowing as its down artifact. - PlanetScale has no admitted VibORM effectful-DDL route. Marker creation and alter survival are proved on the MySQL dialect substrate; a hosted PlanetScale connection can introspect the marker when credentials exist.
- D1 and D1 HTTP support ordinary decimal reads and writes, and libSQL supports
the same SQLite coefficient representation. Migration V1 keeps their offline
generation and read-only inspection paths, but refuses every effectful
pushor migration command before SQL until each provider proves the complete lock, marker-CAS, table-recreation, and foreign-key boundary. Use the provider’s deployment migration system for live schema changes meanwhile.
Raw SQL is physical
Typed model operations apply the descriptor. Raw SQL does not:
- PostgreSQL and MySQL expose their native decimal values through the raw driver’s normal contract.
- SQLite raw reads expose the unscaled integer coefficient, and raw writes must provide a valid coefficient.
- Raw results are not wrapped in
Decimal, because a raw column has no trusted field descriptor.
When a SQLite coefficient may exceed JavaScript’s safe integer range, cast it to text in the SQL before the driver sees it:
const rows = await db.$queryRaw<{ amountCoefficient: string }>`
SELECT CAST("amount" AS TEXT) AS "amountCoefficient"
FROM "invoice"
`;
To recover the logical value yourself, move the decimal point left by the
field’s scale with digit-string or bigint arithmetic. Do not divide a JavaScript
number by 10 ** scale.