Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

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 COLUMN to replace the marker, then DROP CHECK. Narrowing and every list conversion that must rewrite members are refused before effects because MySQL DDL implicitly commits and a column CHECK cannot 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 push or 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.

Was this page helpful?