Skip to content

Instantly share code, notes, and snippets.

@jjhiggz
Last active August 11, 2025 20:13
Show Gist options
  • Select an option

  • Save jjhiggz/34f4c34910b0e9cbffb8770ebea00e3f to your computer and use it in GitHub Desktop.

Select an option

Save jjhiggz/34f4c34910b0e9cbffb8770ebea00e3f to your computer and use it in GitHub Desktop.
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,
};
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