# 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.format` shows that same text, whatever the
  locale, as for a `Uuid`.
- **JSON** is an object of the four fields, each in its own canonical
  text.
- **Storage** is a column per field, `<column>_from` and `<column>_to`
  (each a `char(3)` code), `<column>_rate` (`numeric`) and `<column>_at`
  (`timestamptz(3)`). A rate type's `from:` and `to:` add check
  constraints holding those columns to its currencies
  (`<table>_<column>_from_check` and `<table>_<column>_to_check`), as a
  `Money` type's `currency:` holds its currency column.
- **Shape.** `type.standard_exists`, the error for a declared type with
  a standard type's shape, matches an object of `from`, `to`, `rate` and
  `at`, 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:

```jadpo
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
}
```

```jadpo
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:

```jadpo
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
}
```

```text
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.mismatch

check 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:` and `to:` are a rate type's settings,** each a `Currency`
  variant written out: `type PoundsToEuros = ExchangeRate { from:
  Currency.gbp, to: Currency.eur }`. Either can be used alone, as in
  `type FromPounds = ExchangeRate { from: Currency.gbp }`. On any other
  type they are `type.constraint_base`, and so is `currency:` 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's `from` needs no `attempt`. One fixed to another currency is
  `type.mismatch` wherever the rate was passed from, its label naming
  both currencies (`a rate from EUR to GBP`). The result is in the type's
  `to`. An amount whose currency only the running program knows still
  takes `currency_mismatch` under `attempt`.
- **A rate type is built from a value of `ExchangeRate`,** which is its
  representation, as for any type built on an object
  ([Representation types](built-in-types.md#representation-types)):
  `PoundsToEuros(rate)`, from an `ExchangeRate` or from another type built
  on one. The value is checked against the rules the type adds. These
  fail as `from` and `to`, 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 on `rate.from` and `rate.to`. An arm for a currency the type
  leaves open is `failure.unknown_mapping`. Braces don't build a rate
  type: `PoundsToEuros { ... }` is `type.not_record`, so a rate written
  out is `PoundsToEuros(ExchangeRate { ... })`.
- **A rate read at the boundary is checked against its type.**
  `json.read` and a request body check the whole rate once its fields
  are read, failing `mismatch` with the rule `from` or `to` in its
  `DecodeProblem`. `text.parse` fails `from` or `to`, beside `malformed`.
  OpenAPI shows each fixed currency as an `enum` of its one code, as it
  does for a `Money` type's currency. The API record writes the type as
  `ExchangeRate { from: GBP, to: EUR }`, so changing either shows as an
  API change.

```jadpo
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
    }
}
```

```jadpo
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:

```jadpo
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))
}
```

```text
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.mismatch

check 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](named-types.md#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:

```jadpo
type CakePrice = Money {
    currency: Currency.gbp
    min: 0
}

/// A cake's price for the shop in Paris.
type ParisPrice = CakePrice {
    currency: Currency.eur
}
```

```text
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_contradiction

check failed: 1 error, 1 file
```
