Stream: beginners

Topic: Money VO Domain model


view this post on Zulip BearCarpenter (Aug 29 2026 at 19:26):

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!

view this post on Zulip Jared Ramirez (Aug 29 2026 at 19:35):

can you share the version with a single make function that does not work as expected?

edit: oh i see it now

view this post on Zulip Jared Ramirez (Aug 29 2026 at 19:47):

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

view this post on Zulip Jared Ramirez (Aug 29 2026 at 19:48):

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

view this post on Zulip BearCarpenter (Aug 29 2026 at 19:50):

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?

view this post on Zulip BearCarpenter (Aug 29 2026 at 20:38):

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({})
}

view this post on Zulip BearCarpenter (Aug 30 2026 at 06:00):

@Richard Feldman @Luke Boswell @Jared Ramirez @Anton
how would you implement it?
Library should define huge closed set.

view this post on Zulip Jasper Woudenberg (Aug 30 2026 at 06:53):

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.

view this post on Zulip Luke Boswell (Aug 30 2026 at 07:10):

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.

view this post on Zulip Luke Boswell (Aug 30 2026 at 07:10):

I would also try out lots of different shapes and experiments etc. Basically like you are here.

view this post on Zulip Luke Boswell (Aug 30 2026 at 07:29):

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({})
}

view this post on Zulip Luke Boswell (Aug 30 2026 at 07:31):

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

view this post on Zulip Jared Ramirez (Aug 30 2026 at 07:34):

oh duh :face_palm:, my example of course has the same problem. apologies, i was tired when i responded last night haha

view this post on Zulip Jared Ramirez (Aug 30 2026 at 07:35):

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!

view this post on Zulip BearCarpenter (Aug 30 2026 at 07:40):

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?

view this post on Zulip Jared Ramirez (Aug 30 2026 at 08:07):

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!

view this post on Zulip Jared Ramirez (Aug 30 2026 at 08:07):

then USD and EUR must be opaque nominal types

view this post on Zulip BearCarpenter (Aug 30 2026 at 08:19):

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({})
}

view this post on Zulip Jared Ramirez (Aug 30 2026 at 11:44):

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

view this post on Zulip Jared Ramirez (Aug 30 2026 at 11:45):

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!

view this post on Zulip Richard Feldman (Aug 30 2026 at 12:37):

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:

view this post on Zulip Richard Feldman (Aug 30 2026 at 12:38):

and you'd probably need to actually call those methods to prevent implementing those methods using crash

view this post on Zulip Richard Feldman (Aug 30 2026 at 12:40):

that said, I haven't read the whole thread - just looking at the type questions from the end of the thread haha

view this post on Zulip BearCarpenter (Aug 30 2026 at 13:39):

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?

view this post on Zulip BearCarpenter (Aug 30 2026 at 15:49):

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.

view this post on Zulip BearCarpenter (Aug 30 2026 at 15:55):

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:

view this post on Zulip Jared Ramirez (Aug 30 2026 at 16:59):

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