21 · Money & safety
Can I trust it with money, and with input I don't control?
Yes. Money is exact, an invariant like non-negative is a type the boundary enforces, and untrusted input cannot reach a SQL or shell sink without being sanitized. Each of these is a compile-or-parse error if you get it wrong, not a convention someone has to remember three functions away.
Money is not a float
A financial system on IEEE-754 number is a bug waiting to
be reported: 0.1 + 0.2 is not 0.3, and value
past 253 silently loses precision. std/decimal is
exact base-10 fixed-point over BigInt. Add, subtract, and multiply are
exact; division takes an explicit scale and rounds half away from zero;
a malformed amount parses to an Err, never a silent
NaN.
let price = decimal("10.50")?
let tax = decimal("0.84")?
price.add(tax).to_string() # "11.34", exact
decimal("2")?.div(decimal("3")?, 2) # "0.67", half away from zero
// JS number, the default everywhere: 0.1 + 0.2 // 0.30000000000000004 1_000_000_000_000.10 * 1.07 // rounding drift 9007199254740993 // === 9007199254740992 // None of these are your money anymore.
Ids and counters stay exact past 253
An account id or a ledger counter that overflows the float range and
rounds is a different kind of catastrophe. bigint is an
exact arbitrary-precision integer with 123n literals, and a
record field typed bigint validates
typeof === "bigint" at the boundary, so an id arriving as a
JSON number is rejected rather than quietly truncated.
An invariant is a type, not a comment
"The amount is non-negative" and "the rating is 1 to 5" are the checks
that get written once and forgotten later. A where
refinement puts the invariant in the type, and the descriptor enforces
it at the boundary. A value that fails the predicate never becomes an
Amount in the first place.
type Amount = int where value >= 0 type Rating = int where value >= 1 && value <= 5 Amount.parse(-1) # Err (not just "is a number") Rating.parse(6) # Err Amount.parse(3.5) # Err (int check too)
Injection is a compile error
The classic remote-code path is untrusted input reaching a SQL query, a
shell command, or an HTML template. std/taint makes that a
type error. A value from outside is Tainted; a sink you
want to protect takes Trusted. The two are structurally
distinct, so the only way from one to the other is an explicit
sanitize. Hand a sink the tainted value directly and
tsc refuses to compile it.
fn run_query(sql: Trusted<string>) -> void { ... }
let input: Tainted<string> = taint(req.body)
run_query(sanitize(input, escape)) # ok
let input: Tainted<string> = taint(req.body) run_query(input) // TS2345: 'Tainted<string>' is not // assignable to 'Trusted<string>'.
Where it stands
Shipping today
std/decimal for exact money, bigint for exact large integers, where refinements so an invariant is validated at the boundary, and std/taint so untrusted input can't reach a protected sink without an explicit sanitize. Each is checked: a bad amount or id fails parse, a bad invariant fails parse, and an unsanitized value fails tsc.
Where it stops
A where refinement covers primitive base types today; record and cross-field invariants (where value.paid <= value.total) are a planned extension. Taint is discipline you opt into by typing a sink Trusted, not automatic flow analysis: the compiler enforces the boundary you declare, it does not infer taint across the whole program for you.