Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

Decimal

Exact decimal scalar — string-backed, never rounded through a float

Basic Usage

import { s } from "viborm";

s.decimal(); // Required decimal
s.decimal().nullable(); // string | null
s.decimal().default("0"); // With default

A decimal reads back as a string — the exact canonical spelling of the value in the column, at any precision:

await db.invoice.create({ data: { total: "1234.56" } });

const invoice = await db.invoice.findFirstOrThrow();
invoice.total; // "1234.56"  (a string, not 1234.56)

This is not a stylistic choice. numeric on Postgres and DECIMAL(65,30) on MySQL hold values a JavaScript number cannot: a number is an IEEE-754 double, good for about 15 significant digits. Handing you a number would mean handing you a value that quietly disagrees with the database — which is exactly what s.decimal() exists to prevent. Use s.float() when approximate is genuinely fine.

Writing

Writes accept a string or a number.

await db.invoice.create({ data: { total: "1234.56" } }); // exact
await db.invoice.create({ data: { total: 1234.56 } }); // convenient

A string must be an exact decimal literal: an optional sign, digits, and at most one dot. No exponent, no whitespace, no NaN — anything else is a validation error rather than a guess.

"1234.56"; // ok
"-0.000000000000000000000000000001"; // ok — 30 digits, all of them kept
"1e3"; // rejected: exponent form is a float spelling
"1,5"; // rejected

A number is accepted for convenience, with one caveat worth internalising: a JS number is already a double, so any float error your code made before handing it over travels in with it. viborm binds the double’s own exact spelling rather than inventing a tidier value you never had.

await db.invoice.create({ data: { total: 0.1 + 0.2 } });
// stored as 0.30000000000000004 — the value you actually passed

If the number came from user input or arithmetic, keep it a string end-to-end.

One value, one spelling

Values are canonicalized on the way in and on the way out, so a number has exactly one representation:

"1.10"  ->  "1.1"
"+1.5"  ->  "1.5"
"007"   ->  "7"
"-0.00" ->  "0"

{ total: "1.1" } and { total: "1.10" } therefore match the same rows.

Comparison and arithmetic happen in the database

No decimal ever passes through JavaScript float math. Filter operands are bound into the dialect’s exact decimal type (CAST(? AS NUMERIC) on Postgres, CAST(? AS DECIMAL(65,30)) on MySQL), and atomic updates run server-side:

await db.account.update({
  where: { id },
  data: { balance: { increment: "0.2" } }, // exact, in SQL
});

Starting from 0.1, that leaves 0.3 — not 0.30000000000000004.

Aggregates over decimals come back as exact strings too:

const { _sum } = await db.invoice.aggregate({ _sum: { total: true } });
_sum.total; // "10450.75"  (string)

SQLite: what is exact and what is refused

SQLite has no exact decimal type. Its DECIMAL is a spelling with NUMERIC affinity, so any fractional value lands in a double. viborm therefore stores decimals in a TEXT column on SQLite, which keeps storage and reads exact at any precision — and refuses the operations it could only answer through a double, rather than answering them approximately.

Operation postgres mysql sqlite
Read / write round-trip exact exact exact
equals, not, in, notIn exact exact exact
lt / lte / gt / gte, orderBy exact exact UnsupportedOperationError
_min / _max / _sum / _avg exact exact UnsupportedOperationError
increment / decrement / multiply / divide exact exact UnsupportedOperationError
set, count, distinct, groupBy on the column exact exact exact

Equality is exact on SQLite because every value has one canonical spelling, so text equality is numeric equality. Ordering is not: byte order puts "10" before "9", and the cast that would fix it goes through a double and starts losing digits past ~15 significant figures. A gt that silently skips a row is worse than an error, so it is an error.

orderBy” means every spelling of it, not just the bare one — an ordering you cannot get right paginated is not an ordering you can get right at all. So the refusal also covers take / skip / cursor windows and findFirst (which is take: 1), ordering through a to-one relation (orderBy: { author: { fee: "asc" } }), ordering inside a nested read (include: { entries: { orderBy: { amount: "asc" } } }), groupBy’s orderBy — by a grouped decimal or by _min/_max/_sum/_avg of one — and groupBy’s having on those same four aggregates. A decimal primary key or unique key is refused the moment a window is opened over it too, because pagination silently adds it as the tie-breaker that decides page boundaries. _count is never refused anywhere: counting rows needs no ordering.

If you need ordered decimals on SQLite, the two honest options are s.float() (approximate, and says so) or scaled integers in an s.bigInt() — cents rather than dollars.

Column types

postgres mysql sqlite
Default numeric DECIMAL(65,30) TEXT

Bare DECIMAL on MySQL would mean DECIMAL(10,0) and silently truncate every fraction, so the default matches Prisma’s DECIMAL(65,30). Override any of them via Native Types.

Migrating from number decimals

Reads change from number to string. In most code that is Number(row.total) at the display edge, or nothing at all if the value was already being formatted. Comparisons in JS (row.total > 100) are the ones to look for — prefer pushing them into the query, where they are exact anyway.

For a deploy that cannot change every read at once, there is a transitional client option:

const db = createClient({
  schema,
  driver,
  decimal: "number", // legacy decode — removed next release
});

It restores the old runtime shape only. The static types still say string, so your editor will keep flagging every site, and the precision loss comes back with it. Writes and filters stay exact underneath, so the digits remain in the database and turning the option off recovers them. Treat it as a migration aid with a deadline, not a supported mode.

Examples

import { pipe, string, regex } from "valibot";

// Money
const total = s.decimal().default("0");

// A price that must carry exactly two decimal places
const price = s.decimal().schema(pipe(string(), regex(/^\d+\.\d{2}$/)));

// Nullable percentage
const discountRate = s.decimal().nullable();

.schema() on a decimal refines the string form, since that is what the scalar produces.

Was this page helpful?