Last active
August 11, 2025 20:13
-
-
Save jjhiggz/34f4c34910b0e9cbffb8770ebea00e3f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { z } from "zod"; | |
| // Zod schemas with branding | |
| export const DollarsSchema = z.number().nonnegative().brand("dollars"); | |
| export const CentsSchema = z.number().int().nonnegative().brand("cents"); | |
| export type Dollars = z.infer<typeof DollarsSchema>; | |
| export type Cents = z.infer<typeof CentsSchema>; | |
| type DollarVal = Dollars | null | undefined; | |
| type CentVal = Cents | null | undefined; | |
| type DollarVars = Record<string, DollarVal>; | |
| type CentVars = Record<string, CentVal>; | |
| export type MoneyVars = Record<string, Dollars | Cents | null | undefined>; | |
| export type AsType = "dollars" | "cents"; | |
| type NullishNumber<T> = null extends T | |
| ? (undefined extends T ? number | null | undefined : number | null) | |
| : undefined extends T | |
| ? number | undefined | |
| : number; | |
| type NormalizeToDollarNumbers<T extends MoneyVars> = { | |
| [K in keyof T]: NullishNumber<T[K]>; | |
| }; | |
| export type CalculateFn<T extends MoneyVars, R extends number | undefined> = ( | |
| vars: NormalizeToDollarNumbers<T> | |
| ) => R; | |
| export type FormatOptions = { | |
| unit?: AsType; | |
| accuracy?: number; | |
| showDecimals?: boolean; | |
| showCurrency?: boolean; | |
| currency?: string; | |
| undefinedValue?: string; | |
| shouldFormatUndefinedAsZero?: boolean; | |
| }; | |
| export type ValueType<TMayU extends boolean> = TMayU extends true ? number | undefined : number; | |
| export class MoneyBuilder<TVars extends MoneyVars = {}, TMayUndefined extends boolean = false> { | |
| private dollarVars: DollarVars = {}; | |
| private centVars: CentVars = {}; | |
| private calcResult: number | undefined = undefined; | |
| static inputDollars<T extends Record<string, DollarVal>>(vars: T) { | |
| const builder = new MoneyBuilder<T, false>(); | |
| builder.dollarVars = { ...vars }; | |
| return builder as MoneyBuilder<T, false>; | |
| } | |
| inputCents<T extends Record<string, CentVal>>(vars: T) { | |
| this.centVars = { ...vars }; | |
| return this as unknown as MoneyBuilder<TVars & T, TMayUndefined>; | |
| } | |
| calculate( | |
| fn: CalculateFn<TVars & CentVars, number> | |
| ): MoneyBuilder<TVars & CentVars, false>; | |
| calculate( | |
| fn: CalculateFn<TVars & CentVars, number | undefined> | |
| ): MoneyBuilder<TVars & CentVars, true>; | |
| calculate(fn: any): any { | |
| const keys = new Set<string>([ | |
| ...Object.keys(this.dollarVars), | |
| ...Object.keys(this.centVars), | |
| ]); | |
| const varsInDollars: Record<string, number | null | undefined> = {}; | |
| for (const key of keys) { | |
| if (Object.prototype.hasOwnProperty.call(this.dollarVars, key)) { | |
| const v = this.dollarVars[key]; | |
| varsInDollars[key] = v == null ? (v as null | undefined) : (v as number); | |
| } else { | |
| const v = this.centVars[key]; | |
| varsInDollars[key] = v == null ? (v as null | undefined) : (v as number) / 100; | |
| } | |
| } | |
| const result = fn(varsInDollars); | |
| this.calcResult = result as number | undefined; | |
| return this as any; | |
| } | |
| value(unit: AsType = "dollars"): ValueType<TMayUndefined> { | |
| const base = this.calcResult; | |
| if (base === undefined) return undefined as ValueType<TMayUndefined>; | |
| return (unit === "dollars" ? base : base * 100) as ValueType<TMayUndefined>; | |
| } | |
| mapTo<R>(unit: AsType, fn: (value: ValueType<TMayUndefined>) => R): R { | |
| return fn(this.value(unit)); | |
| } | |
| format(options: FormatOptions = {}): string { | |
| const { | |
| unit = "dollars", | |
| showCurrency = false, | |
| currency = "$", | |
| undefinedValue, | |
| shouldFormatUndefinedAsZero, | |
| } = options; | |
| let val = this.value(unit) as number | undefined; | |
| if (val === undefined) { | |
| if (shouldFormatUndefinedAsZero) { | |
| val = 0; | |
| } else { | |
| return undefinedValue ?? ""; | |
| } | |
| } | |
| const decimals = options.showDecimals === false ? 0 : options.accuracy ?? 2; | |
| const numStr = Number(val).toFixed(decimals); | |
| return `${showCurrency ? currency : ""}${numStr}`; | |
| } | |
| } | |
| export const Money = { | |
| inputDollars: MoneyBuilder.inputDollars, | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { describe, test, expect } from "bun:test"; | |
| import { Money, DollarsSchema, CentsSchema } from "./money"; | |
| describe("Money utils", () => { | |
| test("sum dollars + cents returns dollars by default", () => { | |
| const dollars = DollarsSchema.parse(12.5); | |
| const cents = CentsSchema.parse(50); | |
| const res = Money.inputDollars({ a: dollars }) | |
| .inputCents({ b: cents }) | |
| .calculate(({ a, b }) => a + b) | |
| .value("dollars"); | |
| expect(res).toBeCloseTo(13.0); | |
| }); | |
| test("value in cents", () => { | |
| const dollars = DollarsSchema.parse(1.23); | |
| const cents = Money.inputDollars({ d: dollars }) | |
| .calculate(({ d }) => d) | |
| .value("cents"); | |
| expect(cents).toBe(123); | |
| }); | |
| test("optional inputs propagate nullish types and can format as zero", () => { | |
| const dollars = DollarsSchema.parse(2); | |
| const pretty = Money.inputDollars({ a: dollars, b: undefined }) | |
| .inputCents({ c: null }) | |
| .calculate(({ a, b, c }) => (a ?? 0) + (b ?? 0) + (c ?? 0)) | |
| .format({ unit: "dollars", showCurrency: true, shouldFormatUndefinedAsZero: true }); | |
| expect(pretty).toBe("$2.00"); | |
| }); | |
| test("calculate may return undefined and format fallback is used", () => { | |
| const dollars = DollarsSchema.parse(5); | |
| const out = Money.inputDollars({ base: dollars }) | |
| .calculate(({ base }) => (base > 10 ? base : undefined)) | |
| .format({ unit: "dollars", undefinedValue: "-" }); | |
| expect(out).toBe("-"); | |
| }); | |
| test("mapTo passes requested unit", () => { | |
| const dollars = DollarsSchema.parse(3.33); | |
| const label = Money.inputDollars({ x: dollars }) | |
| .calculate(({ x }) => x) | |
| .mapTo("dollars", (n) => `Amount: $${n?.toFixed(2)}`); | |
| expect(label).toBe("Amount: $3.33"); | |
| }); | |
| describe("fun unit conversion examples", () => { | |
| test("adding a nickel plus a quarter", () => { | |
| const nickel = CentsSchema.parse(5); | |
| const quarter = CentsSchema.parse(25); | |
| const total = Money.inputDollars({}) | |
| .inputCents({ nickel, quarter }) | |
| .calculate(({ nickel, quarter }) => nickel + quarter) | |
| .format({ unit: "cents", showDecimals: false }); | |
| expect(total).toBe("30"); | |
| }); | |
| test("add 4 quarters", () => { | |
| const quarter1 = CentsSchema.parse(25); | |
| const quarter2 = CentsSchema.parse(25); | |
| const quarter3 = CentsSchema.parse(25); | |
| const quarter4 = CentsSchema.parse(25); | |
| const total = Money.inputDollars({}) | |
| .inputCents({ quarter1, quarter2, quarter3, quarter4 }) | |
| .calculate(({ quarter1, quarter2, quarter3, quarter4 }) => | |
| quarter1 + quarter2 + quarter3 + quarter4 | |
| ) | |
| .format({ unit: "dollars", showCurrency: true }); | |
| expect(total).toBe("$1.00"); | |
| }); | |
| test("add 2 quarters to a 10 dollar bill", () => { | |
| const tenDollarBill = DollarsSchema.parse(10); | |
| const quarter1 = CentsSchema.parse(25); | |
| const quarter2 = CentsSchema.parse(25); | |
| const total = Money.inputDollars({ tenDollarBill }) | |
| .inputCents({ quarter1, quarter2 }) | |
| .calculate(({ tenDollarBill, quarter1, quarter2 }) => | |
| tenDollarBill + quarter1 + quarter2 | |
| ) | |
| .format({ unit: "dollars", showCurrency: true }); | |
| expect(total).toBe("$10.50"); | |
| }); | |
| test("add a 10 dollar bill to a 2 dollar bill", () => { | |
| const tenDollarBill = DollarsSchema.parse(10); | |
| const twoDollarBill = DollarsSchema.parse(2); | |
| const total = Money.inputDollars({ tenDollarBill, twoDollarBill }) | |
| .calculate(({ tenDollarBill, twoDollarBill }) => | |
| tenDollarBill + twoDollarBill | |
| ) | |
| .format({ unit: "dollars", showCurrency: true, showDecimals: false }); | |
| expect(total).toBe("$12"); | |
| }); | |
| test("demonstrates units are converted to same value (dollars) in calculate", () => { | |
| const oneDollar = DollarsSchema.parse(1.00); | |
| const hundredCents = CentsSchema.parse(100); | |
| // Both should be normalized to 1.0 in the calculate function | |
| const result = Money.inputDollars({ oneDollar }) | |
| .inputCents({ hundredCents }) | |
| .calculate(({ oneDollar, hundredCents }) => { | |
| // At this point, both oneDollar and hundredCents are 1.0 (in dollars) | |
| expect(oneDollar).toBe(1.0); | |
| expect(hundredCents).toBe(1.0); | |
| return oneDollar + hundredCents; | |
| }) | |
| .value("dollars"); | |
| expect(result).toBe(2.0); | |
| }); | |
| }); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment