Exchange rates
An ExchangeRate says how much one currency is worth in another at a
moment, so you can convert an amount. It is a standard object with four
fields: from and to, each a Currency; rate, a Decimal; and
at, the Instant it was quoted at. You build one with its object
literal and read its fields. One unit of from is rate of to, as a
currency pair is quoted: GBP/EUR 1.17 means £1 is €1.17.
A rate can be any Decimal. It isn't checked to be above 0, because no
rule a field can carry says "greater than" and a rate is a provider's
data. A rate between a currency and itself is allowed.
- Text. Its canonical text is the pair, the rate and the instant,
GBP/EUR 1.17 2026-10-18T09:00:00Z: the ISO 4217 codes, base first, joined by/as a currency pair is written; the rate with the places it was given; and the instant in UTC. Codes are read ignoring case. No standard writes a rate with its time, so this is the narrowest text that round-trips.text.formatshows that same text, whatever the locale, as for aUuid. - JSON is an object of the four fields, each in its own canonical text.
- Storage is a column per field,
<column>_fromand<column>_to(each achar(3)code),<column>_rate(numeric) and<column>_at(timestamptz(3)). A rate type'sfrom:andto:add check constraints holding those columns to its currencies (<table>_<column>_from_checkand<table>_<column>_to_check), as aMoneytype'scurrency:holds its currency column. - Shape.
type.standard_exists, the error for a declared type with a standard type's shape, matches an object offrom,to,rateandat, by their common names.
A shop that takes orders from abroad is given a rate each morning, and
prices a cake in the customer's currency. money.convert takes a rate
only from the amount's currency. Where the checker knows both
currencies, a rate the wrong way round doesn't compile. Otherwise the
call fails currency_mismatch, and a condition on rate.from proves
that away:
type CakePrice = Money { currency: Currency.gbp min: 0}type Payment = Money {}type WrongRate = Failure { kind: FailureKind.invalid_value}/// A cake's price in the rate's other currency, at a rate the shop was/// given; a rate from any other currency fails.function priced_abroad(cake: CakePrice, rate: ExchangeRate) fails WrongRate -> Payment { var charged: Money = attempt money.convert(cake, rate) { currency_mismatch: WrongRate // a rate from euros, the wrong way round } return Payment(charged)}/// The same, with the rate's currency checked first, so it needs no/// `attempt`.function priced_if_from_pounds(cake: CakePrice, rate: ExchangeRate) -> Payment? { if (rate.from == Currency.gbp) { return Payment(money.convert(cake, rate)) } return none}
test "a rate converts from its own currency only" { var pounds_to_euros: ExchangeRate = ExchangeRate { from: Currency.gbp to: Currency.eur rate: 1.17 at: "2026-10-18T09:00:00Z" } var euros_to_pounds: ExchangeRate = ExchangeRate { from: Currency.eur to: Currency.gbp rate: 0.85 at: "2026-10-18T09:00:00Z" } var cake: CakePrice = CakePrice(money.from_minor(1050, Currency.gbp)) var in_euros: Payment = attempt priced_abroad(cake, pounds_to_euros) assert in_euros == money.from_minor(1228, Currency.eur) // €12.285, half to even assert priced_abroad(cake, euros_to_pounds) fails WrongRate assert priced_if_from_pounds(cake, pounds_to_euros) == money.from_minor(1228, Currency.eur) assert priced_if_from_pounds(cake, euros_to_pounds) == none assert text.from(pounds_to_euros) == "GBP/EUR 1.17 2026-10-18T09:00:00Z"}
The checker knows an amount's currency from its type or from how it was
made. It knows a rate's from and to from its object literal written
out, or from a condition such as rate.from == Currency.gbp or
rate.from == money.currency(price); such a condition also settles
currency_mismatch, with no attempt. Where it knows both, a rate from
another currency is a type error, type.mismatch, not a rule of its
own. Elsewhere the call fails currency_mismatch under attempt. The
result is in the rate's to currency where that is known, so building a
type that fixes that currency needs no arm.
Here the condition says the rate is from euros, and the cake is in pounds:
type CakePrice = Money { currency: Currency.gbp}type Payment = Money {}/// A cake's price at a rate quoted from euros.function priced_abroad(cake: CakePrice, rate: ExchangeRate) -> Payment? { if (rate.from == Currency.eur) { return Payment(money.convert(cake, rate)) } return none}
error: `rate` converts from EUR, and `cake` is in GBP --> main.jadpo:10:44 |10 | return Payment(money.convert(cake, rate)) | ^^^^ a rate from EUR reason: `money.convert` applies a rate to an amount in the rate's `from` currency. `cake` is in GBP and `rate` converts from EUR, as their types, how they were made or a condition around the call fix, so the rate is the wrong way round or for another currency. next: Pass a rate from GBP, or an amount in EUR (agent) rule: type.mismatch help: https://jadpo.dev/docs/errors/type.mismatchcheck failed: 1 error, 1 file
A type built on ExchangeRate can fix its currencies with from: and
to:, as a Money type fixes its currency with currency:. A
signature then carries them. The right rate needs no attempt, the
wrong one doesn't compile wherever it is passed, and a rate the app was
given, or reads from JSON or text, is checked against them where it
becomes one:
from:andto:are a rate type's settings, each aCurrencyvariant written out:type PoundsToEuros = ExchangeRate { from: Currency.gbp, to: Currency.eur }. Either can be used alone, as intype FromPounds = ExchangeRate { from: Currency.gbp }. On any other type they aretype.constraint_base, and so iscurrency:on a rate type.- The checker reads the rate's type first, before a rate written out
or a condition on
rate.from. An amount whose type or making fixes the rate type'sfromneeds noattempt. One fixed to another currency istype.mismatchwherever the rate was passed from, its label naming both currencies (a rate from EUR to GBP). The result is in the type'sto. An amount whose currency only the running program knows still takescurrency_mismatchunderattempt. - A rate type is built from a value of
ExchangeRate, which is its representation, as for any type built on an object (Representation types):PoundsToEuros(rate), from anExchangeRateor from another type built on one. The value is checked against the rules the type adds. These fail asfromandto, rule keys named for their settings, and are settled with no arm by the value's own type, a rate written out, or a condition onrate.fromandrate.to. An arm for a currency the type leaves open isfailure.unknown_mapping. Braces don't build a rate type:PoundsToEuros { ... }istype.not_record, so a rate written out isPoundsToEuros(ExchangeRate { ... }). - A rate read at the boundary is checked against its type.
json.readand a request body check the whole rate once its fields are read, failingmismatchwith the rulefromortoin itsDecodeProblem.text.parsefailsfromorto, besidemalformed. OpenAPI shows each fixed currency as anenumof its one code, as it does for aMoneytype's currency. The API record writes the type asExchangeRate { from: GBP, to: EUR }, so changing either shows as an API change.
type CakePrice = Money { currency: Currency.gbp min: 0}type EuroPrice = Money { currency: Currency.eur}/// The rate a shop is given each morning for pricing in euros.type PoundsToEuros = ExchangeRate { from: Currency.gbp // fixed: a rate from euros doesn't compile to: Currency.eur}type QuoteJson = Text {}type WrongRate = Failure { kind: FailureKind.invalid_value}type NotARate = Failure { kind: FailureKind.invalid_value}/// A cake's price in euros: the rate's type fixes both currencies, so/// the call needs no `attempt` and the result is in euros.function priced_in_euros(cake: CakePrice, rate: PoundsToEuros) -> EuroPrice { return EuroPrice(money.convert(cake, rate))}/// The morning's rate, checked once, where it arrives.function morning_rate(todays_quote: ExchangeRate) fails WrongRate -> PoundsToEuros { return attempt PoundsToEuros(todays_quote) { from: WrongRate // a rate from euros, the wrong way round to: WrongRate // a rate to dollars }}/// A rate a provider sent as JSON.function sent_rate(sent: QuoteJson) fails NotARate -> PoundsToEuros { return attempt json.read(sent, as: PoundsToEuros) { malformed: NotARate mismatch: NotARate // other currencies }}
test "a rate type converts from its own currency only" { var todays_quote: ExchangeRate = ExchangeRate { from: Currency.gbp to: Currency.eur rate: 1.17 at: "2026-10-18T09:00:00Z" } var backwards_quote: ExchangeRate = ExchangeRate { from: Currency.eur to: Currency.gbp rate: 0.85 at: "2026-10-18T09:00:00Z" } var rate: PoundsToEuros = attempt morning_rate(todays_quote) var cake: CakePrice = CakePrice(money.from_minor(1050, Currency.gbp)) assert priced_in_euros(cake, rate) == money.from_minor(1228, Currency.eur) assert morning_rate(backwards_quote) fails WrongRate var sent: PoundsToEuros = attempt sent_rate( "{\"from\":\"GBP\",\"to\":\"EUR\",\"rate\":\"1.17\",\"at\":\"2026-10-18T09:00:00Z\"}" ) assert sent == rate assert sent_rate( "{\"from\":\"EUR\",\"to\":\"GBP\",\"rate\":\"0.85\",\"at\":\"2026-10-18T09:00:00Z\"}" ) fails NotARate}
A rate whose type is the wrong way round is a type error in the function it is passed to, with no condition in sight:
type CakePrice = Money { currency: Currency.gbp}type Payment = Money {}type EurosToPounds = ExchangeRate { from: Currency.eur to: Currency.gbp}/// A cake's price, at a rate whose type is the wrong way round.function priced_abroad(cake: CakePrice, rate: EurosToPounds) -> Payment { return Payment(money.convert(cake, rate))}
error: `rate` converts from EUR, and `cake` is in GBP --> main.jadpo:14:40 |14 | return Payment(money.convert(cake, rate)) | ^^^^ a rate from EUR to GBP reason: `money.convert` applies a rate to an amount in the rate's `from` currency. `cake` is in GBP and `rate` converts from EUR, as their types, how they were made or a condition around the call fix, so the rate is the wrong way round or for another currency. next: Pass a rate from GBP, or an amount in EUR (agent) rule: type.mismatch help: https://jadpo.dev/docs/errors/type.mismatchcheck failed: 1 error, 1 file
A type built on one that fixes a currency repeats that currency or
leaves it out, because a type built on another can only narrow it
(Constraints). A different
currency, in an amount's currency: or a rate's from: and to:,
leaves no value that could follow both. It is
type.constraint_contradiction, as a scale: other than the inherited
one is, and its one-step fix removes the setting. A currency the parent
leaves open, such as to: on a type built on FromPounds, may be
fixed:
type CakePrice = Money { currency: Currency.gbp min: 0}/// A cake's price for the shop in Paris.type ParisPrice = CakePrice { currency: Currency.eur}
error: `currency: Currency.eur` is different from `CakePrice`'s `currency: Currency.gbp` --> main.jadpo:8:15 |8 | currency: Currency.eur | ^^^^^^^^^^^^ different from `CakePrice`'s `currency: Currency.gbp` reason: `CakePrice` fixes the currency at GBP, and a type built on another only narrows it, so it repeats that currency or leaves it out; with another one no value could follow both, and the type could never be built. next: Remove `currency:`, so the type keeps `CakePrice`'s GBP (agent) rule: type.constraint_contradiction help: https://jadpo.dev/docs/errors/type.constraint_contradictioncheck failed: 1 error, 1 file
Markdown for agents: /docs/exchange-rates.md.Built from Jadpo main at a707efb.