Money
Amounts of money must not be JavaScript floats: 0.1 + 0.2 is not 0.3. Two safe choices are a decimal string (what t.decimal() gives) or a whole number of cents. This recipe defines a custom type that keeps cents in your code and a numeric column in the database.
Setup
The code of this recipe depends on these declarations: the imports, the schema classes, the repo, and the migration that creates the tables. Run the recipe in an empty database of its own: each recipe creates its own tables, and some have the same names (posts) with other columns.
ts
import { postgres } from "@egon-orm/postgres";
import { cast, createRepo, defineType, from, Schema, sum, t } from "egon-orm";
import { createMigrator, defineMigration } from "egon-orm/migration";
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });
// The tables, created by a migration.
const tables = defineMigration({
up(m) {
m.createTable("products", (table) => {
table.id({ type: "integer" });
table.text("name").notNull();
table.decimal("price", { precision: 12, scale: 2 }).notNull();
});
},
down(m) {
m.dropTable("products");
},
});
/** Creates the tables, in the database that DATABASE_URL names. */
export async function createTables(): Promise<void> {
const admin = postgres({ connectionString: process.env.DATABASE_URL });
await createMigrator({
adapter: admin,
migrations: [{ version: 20261021000601, name: "money_tables", migration: tables }],
}).up();
await admin.close();
}The type
ts
const AMOUNT = /^(-?)(\d{1,10})(?:\.(\d{1,2}))?$/;
/** "19.99" -> 1999, read digit by digit so that no floating-point error creeps in. */
function toCents(text: string): number | undefined {
const match = AMOUNT.exec(text.trim());
if (match === null) return undefined;
const cents = Number(match[2]) * 100 + Number((match[3] ?? "").padEnd(2, "0"));
return match[1] === "-" ? -cents : cents;
}
/** An amount of money as a whole number of cents, stored in a numeric(12, 2) column. */
export const Cents = defineType<number, string>({
name: "cents",
db: { kind: "decimal" },
cast(input) {
const cents = typeof input === "string" || typeof input === "number" ? toCents(String(input)) : undefined;
return cents === undefined
? { success: false, message: "is not an amount" }
: { success: true, data: cents };
},
fromDb: (stored) => toCents(stored) ?? Number.NaN, // "19.99" -> 1999
toDb: (cents) =>
`${cents < 0 ? "-" : ""}${Math.trunc(Math.abs(cents) / 100)}.${String(Math.abs(cents) % 100).padStart(2, "0")}`,
});castaccepts what forms and JSON send ("19.99",19.99) and refuses anything with more than two decimals, instead of rounding.fromDbandtoDbconvert between the driver's decimal string and cents, digit by digit.equalis left out: cents are numbers, andObject.iscompares them.
Using it
ts
class Product extends Schema("products", {
id: t.integer().primaryKey().dbGenerated(),
name: t.string(),
price: t.type(Cents),
}) {
static changeset(product: Product | typeof Product, params: unknown) {
return cast(product, params, ["name", "price"]).validateRequired(["name", "price"]);
}
get displayPrice(): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(this.price / 100);
}
}The examples below start from these rows, after creating the tables:
ts
await createTables();ts
const pen = await repo.insertOrThrow(Product.changeset(Product, { name: "Pen", price: "19.99" }));
pen.price; // => 1999
pen.displayPrice; // => "$19.99"
Product.changeset(Product, { name: "Pen", price: "19.999" }).errorMessages(); // => { price: ["is not an amount"] }The database does the arithmetic on the column, and the result is read back with the type:
ts
const total = await repo.one(from(Product, "p").select(({ p }) => sum(p.price))); // summed by the databaseGood to know
- A number of cents is exact up to
Number.MAX_SAFE_INTEGER(about 90 trillion dollars). For larger amounts, or for exchange rates, uset.decimal()and a decimal library. - Store the currency next to the amount when there is more than one. A parameterized type can carry it as an option.
- On SQLite a decimal is stored as text and compares as text; integer cents in an
integercolumn sort correctly everywhere.

