Hi everyone! :wave:
I'm trying to implement a type-safe Money value object in Roc. The goal is to catch currency mismatches at compile time when calling add, similar to TypeScript generics (Money<"USD">) or C# phantom types (Money<TCurrency> where TCurrency : ICurrency).
Ideally, add : Money(c), Money(c) -> Money(c) should prevent adding EUR to USD at compile time/or lsp.
However, if I use a single generic make : Dec, currency -> Try(Money(currency), [NegativeAmount]), passing EUR or USD as tags creates open tag unions ([EUR, ..]), causing type widening. As a result, eur.add(usd) unifies the types to Money([EUR, USD, ..]) and compiles successfully.
The only way I’ve managed to strictly enforce compile-time separation is by defining separate constructor functions for every single currency (e.g., makeEUR, makeUSD):
Money(currency) := { amount : Dec, currency : currency }.{
makeEUR : Dec -> Try((Money([EUR])), [NegativeAmount])
makeEUR = |amount| {
make(amount, EUR)
}
makeUSD : Dec -> Try((Money([USD])), [NegativeAmount])
makeUSD = |amount| {
make(amount, USD)
}
amount : Money(currency) -> Dec
amount = |Money.(m)| m.amount
currency : Money(currency) -> currency
currency = |Money.(m)| m.currency
add : Money(c), Money(c) -> Money(c)
add = |Money.(m1), Money.(m2)| {
Money.(
{ amount: m1.amount + m2.amount, currency: m2.currency },
)
}
to_inspect : Money(currency) -> Str
to_inspect = |Money.(m)| {
"${Dec.to_str(m.amount)} ${Str.inspect(m.currency)}"
}
}
make : Dec, currency -> Try((Money(currency)), [NegativeAmount])
make = |amount, currency| {
if amount < 0 {
Err(NegativeAmount)
} else {
Ok(Money.({ amount: amount, currency: currency }))
}
}
expect {
m = Money.makeEUR(1)?
m.currency == EUR
}
expect {
m = Money.makeEUR(1)?
Str.inspect(m.currency) == "EUR"
}
expect {
m1 = Money.makeEUR(1)?
m2 = Money.makeEUR(1)?
total_EUR = m1.add(m2)
Str.inspect(total_EUR) == "2.0 EUR"
}
expect {
m1 = Money.makeUSD(1)?
m2 = Money.makeUSD(1)?
total_usd = m1.add(m2)
Str.inspect(total_usd) == "2.0 USD"
}
# correctly - not working with other currency
# expect {
# m1 = Money.makeEUR(1)?
# m2 = Money.makeUSD(1)?
# total_EUR = m1.add(m2)
# Str.inspect(total_EUR) == "2.0 USD"
# }
This works as expected (m1 = Money.makeEUR(1)? and m2 = Money.makeUSD(1)? will fail on m1.add(m2) at compile time), but it forces me to duplicate makeXYZ methods for every supported currency.
Is there an idiomatic way in Roc to constrain or pin currency to a single closed tag/type in a generic make function, so we don't have to duplicate constructor methods?
Thanks for any insights!
can you share the version with a single make function that does not work as expected?
edit: oh i see it now
i think you can constrain the input type:
Currency : [USD, EUR]
make : Dec, Currency -> Try((Money, Currency), [Negative])
since its unbound, just passing eg USD infers it as open, for it to be closed it must be explicitly closed via type sig
you could also consider using a phantom type as well here, make would be the same but you would not need to store Currency at runtime
I tried with phantom. Generally I started with:
Money(c) := Dec.{
make : Dec, c -> Try((Money(c)), [NegativeAmount])
make = |val, _tag| {
if val < 0 {
Err(NegativeAmount)
} else {
Ok(Money.(val))
}
}
amount : Money(c) -> Dec
amount = |Money.(amount)| amount
add : Money(c), Money(c) -> Money(c)
add = |Money.(v1), Money.(v2)| {
Money.({ v1 + v2 })
}
}
clientExampleUsage = || {
my_money1 = Money.make(100.50, EUR)?
my_money2 = Money.make(20.00, EUR)?
_legal = my_money1.add(my_money2) # correct
usd_money = Money.make(50.00, USD)?
_illegal = my_money1.add(usd_money) # incorrect - it is possible
Ok({})
}
but next I thought - what if library define each possible currency?
and this. i have no idea... Require invariance
Money(c) := { amount : Dec, currency : c }.{
Currency : [USD, EUR]
make : Dec, Currency -> Try((Money(Currency)), [NegativeAmount])
make = |amount, currency| {
if amount < 0 {
Err(NegativeAmount)
} else {
m = Money.({ amount, currency })
Ok(m)
}
}
amount : Money(currency) -> Dec
amount = |Money.(m)| m.amount
add : Money(currency), Money(currency) -> Money(currency)
add = |Money.(m1), Money.(m2)| {
Money.(
{
amount: m1.amount + m2.amount,
currency: m1.currency,
},
)
}
}
clientExampleUsage = || {
# my_money0 = Money.make(100.50, GBP)? # not working - so this is correctly
my_money1 = Money.make(100.50, EUR)?
my_money2 = Money.make(20.00, EUR)?
_legal = my_money1.add(my_money2) # correct
usd_money = Money.make(50.00, USD)?
_illegal = my_money1.add(usd_money) # incorrect - it is possible
Ok({})
}
@Richard Feldman @Luke Boswell @Jared Ramirez @Anton
how would you implement it?
Library should define huge closed set.
One approach would be to use separate types instead of tags for currencies, like so:
Currencies := {
EUR :: {}
USD :: {}
# ...
}
You could use these as phantom types in your Money constructor - they won't unify with each other.
I would probably start top-down and do some broader research to understand the domain model a bit more, gather the requirements and define a scope or boundary for my package. I'd look for prior-art and relevant research on the topic etc -- and then go from there.
I would also try out lots of different shapes and experiments etc. Basically like you are here.
This works, I don't know if it's a good way to do it :shrug:
$ roc money.roc
EUR total: 120.5
USD total: 50.0
EUR := {}.{
value = EUR.({})
}
USD := {}.{
value = USD.({})
}
Money(currency) := { amount : Dec, currency : currency }.{
make : Dec, currency -> Try(Money(currency), [NegativeAmount])
make = |amount, currency| {
if amount < 0 {
Err(NegativeAmount)
} else {
Ok(Money.({ amount, currency }))
}
}
amount : Money(currency) -> Dec
amount = |Money.(money)| money.amount
add : Money(currency), Money(currency) -> Money(currency)
add = |Money.(m1), Money.(m2)| {
Money.({
amount: m1.amount + m2.amount,
currency: m1.currency,
})
}
}
main! = |_| {
# Money.make(100.50, GBP.value) does not work: GBP is not declared.
my_money1 = Money.make(100.50, EUR.value) ? |_| Exit(1)
my_money2 = Money.make(20.00, EUR.value) ? |_| Exit(1)
legal = my_money1.add(my_money2)
usd_money = Money.make(50.00, USD.value) ? |_| Exit(1)
echo!("EUR total: ${legal.amount().to_str()}\n")
echo!("USD total: ${usd_money.amount().to_str()}\n")
# Uncommenting this is illegal: Money([EUR]) and Money([USD]) differ.
# illegal = my_money1.add(usd_money)
Ok({})
}
The reason is doesn't work for Currency : [USD, EUR] is because both USD and EUR are both tags and have the same type Currency
oh duh :face_palm:, my example of course has the same problem. apologies, i was tired when i responded last night haha
Jasper Woudenberg said:
One approach would be to use separate types instead of tags for currencies, like so:
Currencies := { EUR :: {} USD :: {} # ... }You could use these as phantom types in your Money constructor - they won't unify with each other.
this is how i would do it!
yeah, but what if library define closed set? it shouldnt work:
# Money([Unkonwn, ..])
unkonwn_money = Money.make(50.00, Unkonwn)?
unkonwn2_money = Money.make(50.00, Unkonwn2)?
# Money([Unkonwn, Unkonwn2, ..])
incorrect_legal = unkonwn_money.add(unkonwn2_money)
only lib set. is makeXYZ is only one correct way?
yes, for constructing values you would need explicit functions, but for all other functions they just need to take and return the same currency type vars!
then USD and EUR must be opaque nominal types
Ok, I thought about something like this is possible:
#pseudocode
clientUsageExampleCheck = || {
# same is legal check
correct_legal_usd1 = Money.make(50.00, Lib.USD)?
correct_legal_usd2 = Money.make(50.00, Lib.USD)?
_correct_legal_sum = correct_legal_usd1 .add(correct_legal_usd2)
# mixed is illegal check
_correct_legal_eur = Money.make(50.00, Lib.EUR)?
# _illegal_mixed_currencies_sum = _correct_legal_eur.add(correct_legal_usd1)
# own is illegal check
# _illegal_unknown1 = Money.make(50.00, My.QWERTY)?
# _illegal_unknown2 = Money.make(50.00, QWERTY)?
Ok({})
}
yeah, the main limitation here is that there’s not a way in roc to create a “closed” catergory of types
you could make Money.make generic over the currency type, but then any type you pass in would be valid. you could constrain it with a static dispatch method (eg is_currency : currency -> ? or something), then define that for USD and EUR, but nothing is stopping anyone from implementing that method on their own custom type
you could actually make is_currency (or whatever) return an opaque type only available in the Money module, so users could not define their own instance of the method and that may work!
I think it would actually need to both accept and return different opaque types that can't be constructed outside the package - otherwise you could just call one of the existing ones to get one of those values :smile:
and you'd probably need to actually call those methods to prevent implementing those methods using crash
that said, I haven't read the whole thread - just looking at the type questions from the end of the thread haha
Am I seeing this correctly that it’s working properly? Could you check it and look for the error?
# Money.roc
Money(c) := Dec.{
make : Dec, BrandedCurrency(c) -> Try(Money(c), [NegativeAmount])
make = |amount, _currency| {
if amount < 0 {
Err(NegativeAmount)
} else {
Ok(Money.({ amount }))
}
}
amount : Money(c) -> Dec
amount = |Money.(amount)| amount
add : Money(c), Money(c) -> Money(c)
add = |Money.(amount1), Money.(amount2)| {
Money.({ amount1 + amount2 })
}
usd : BrandedCurrency([USD])
usd = BrandedCurrency.({ currency: USD })
eur : BrandedCurrency([EUR])
eur = BrandedCurrency.({ currency: EUR })
}
BrandedCurrency(c) := { currency : c }.{}
and
# checkMoney.roc
import Money
main! = |_arg| {
# same is legal check
correct_legal_usd1 = Money.make(50.00, Money.usd)?
correct_legal_usd2 = Money.make(50.00, Money.usd)?
_correct_legal_sum = correct_legal_usd1.add(correct_legal_usd2)
# mixed is illegal check
correct_legal_eur = Money.make(50.00, Money.eur)?
_illegal_mixed_currencies_sum = correct_legal_eur.add(correct_legal_usd1)
# own is illegal check
# _illegal_unknown1 = Money.make(50.00, My.QWERTY)?
_illegal_unknown2 = Money.make(50.00, QWERTY)?
Ok({})
}
is it posible to hack/break this? client (checkMoney.roc) does not have access to create BrandedCurrency from Money.roc?
oh. this will be incorrectly legal
# checkMoney.roc - client
# ...
m1 = Money.({ amount: 1, currency: QWERTY })
m2 = Money.({ amount: 1, currency: USD })
sum = m1.add(m2)
Ok({})
}
I thought := gives private constructor.
it works:
# Money.roc
Money(c) :: { amount : Dec, currency : c }.{
make : Dec, BrandedCurrency(c) -> Try(Money(c), [NegativeAmount])
make = |amount, currency| {
if amount < 0 {
Err(NegativeAmount)
} else {
Ok(Money.({ amount, currency: currency.currency }))
}
}
amount : Money(c) -> Dec
amount = |Money.(m)| m.amount
add : Money(c), Money(c) -> Money(c)
add = |Money.(m1), Money.(m2)| {
Money.({ amount: m1.amount + m2.amount, currency: m1.currency })
}
to_inspect : Money(c) -> Str
to_inspect = |Money.(m)| {
"${Dec.to_str(m.amount)} ${Str.inspect(m.currency)}"
}
usd : BrandedCurrency([USD])
usd = BrandedCurrency.({ currency: USD })
eur : BrandedCurrency([EUR])
eur = BrandedCurrency.({ currency: EUR })
}
BrandedCurrency(c) := { currency : c }.{}
expect {
usd : Money([USD])
usd = Money.make(1, Money.usd)?
Str.inspect(usd) == "1.0 USD"
}
expect {
usd = Money.({ amount: 1, currency: Money.usd })
Str.inspect(usd) == "1.0 USD"
}
expect {
m = Money.make(1, Money.usd)?
m.amount == 1
}
expect {
m = Money.make(1, Money.usd)?
m.currency == USD
}
expect {
m = Money.make(1, Money.usd)?
Str.inspect(m.currency) == "USD"
}
expect {
m1 = Money.make(1, Money.usd)?
m2 = Money.make(1, Money.usd)?
total_usd = m1.add(m2)
Str.inspect(total_usd) == "2.0 USD"
}
expect {
m1 = Money.make(1, Money.eur)?
m2 = Money.make(1, Money.eur)?
total_EUR = m1.add(m2)
Str.inspect(total_EUR) == "2.0 EUR"
}
expect {
x = 1 |> Money.make(Money.usd)?
Str.inspect(x) == "1.0 USD"
}
# correctly - not working with other currency
# expect {
# m1 = Money.make(1, Money.usd)?
# m2 = Money.make(1, Money.eur)?
# total_EUR = m1.add(m2) # illegal
# True
# }
# correctly - not working with own currencies
# expect {
# m1 = Money.make(1, QWERTY)?
# True
# }
# checkMoney.roc - client
import Money
main! = |_arg| {
# same is legal check
correct_legal_usd1 = Money.make(50.00, Money.usd)?
correct_legal_usd2 = Money.make(50.00, Money.usd)?
_correct_legal_sum = correct_legal_usd1.add(correct_legal_usd2)
# mixed is illegal check
correct_legal_eur = Money.make(50.00, Money.eur)?
_illegal_mixed_currencies_sum = correct_legal_eur.add(correct_legal_usd1) # error as expected
# own is illegal check
# _illegal_unknown1 = Money.make(50.00, My.QWERTY)?
_illegal_unknown2 = Money.make(50.00, QWERTY)? # error as expected
# client cant create own Money
m1 = Money.({ amount: 1, currency: QWERTY }) # error as expected
m2 = Money.({ amount: 1, currency: USD }) # error as expected
Ok({})
}
I need read modules docs... :rolling_eyes:
nice! :grinning:
unless you want to do runtime things with currency, can can do:
Money :: { amount : Dec }.{
# no negative check to keep example simple
make : Dec, BrandedCurrency(c) -> Money(c)
make = |a, _| { amount: a }
usd : BrandedCurrency([USD])
usd = {}
eur : BrandedCurrency([EUR])
eur = {}
…
}
BrandedCurrency :: {}
this is equivalent as far as the types are concerned, without having to carry the currency as a runtime value! if you ever need an fn to operate on just eg EUR, you just constrain it in the type signature. (this is called a phantom type)
Last updated: Sep 03 2026 at 15:16 UTC