Avoiding danger using types

DevelopmentHaskell

I work on a system which continuously spends real money without user input. It creates social media ads and bugs could have severe real-life impact (gross overspending, spamming infinite ads, brand damage etc.)

What would you do if you caused a bug that erroneously spent $100 000? How could you prevent those bugs and how far would you go?

We could use spending caps, monitoring, and other external protections, but how do we change our way of writing code to protect ourselves?

An example of danger

Most ad platforms ask you to set an account-wide currency. This means you won't be specifying a currency when you actually buy an ad. That makes it easy for end-users, but could trip you up as an API user dealing with different accounts.

For example, Meta's Ad API looks roughly like this:

createAd :: Account -> Int -> AdContent -> _
createAd account budget adContent = [...]

You provide the account, the budget, and the content of the ad.

One day a user wants an ad for 10 000 SEK, so we call the API with a budget of 10 000. The next day we see that over 90 000 SEK has been spent ... what?!

Oh, our user had their account set to USD, so we ordered an ad for 9 times more than we wanted. Ouch!

How do we avoid this? Check the currency of the account before creating each ad?

Let's write a function for that:

isAccountUsingSEK :: Account -> Bool
isAccountUsingSEK account = case accountCurrency account of -- Imaginary function "SEK" -> True _ -> False

OK, so we always want to run isAccountUsingSEK before createAd.

But what if we forget? Six months from now, when we're in a hurry, will we remember?

Should we perform the currency check inside createAd to ensure it always happens? We could, but then we're extending createAd's responsibility and making testing more difficult. How else can we ensure it's always checked?

Types to the rescue

We could change the signature of createAd so that it no longer takes an Account — it takes an AccountUsingSEK.

We could then ensure that the only way to create an AccountUsingSEK is by calling isAccountUsingSEK. This neatly wraps up the whole problem:

data AccountUsingSEK = { -- Hide this constructor from createAd
    id: Text,
    [...]
}

isAccountUsingSEK :: Account -> Maybe AccountUsingSEK
isAccountUsingSEK account = case accountCurrency account of -- Imaginary function "SEK" -> Just AccountUsingSEK { id: account.id } _ -> Nothing createAd :: AccountUsingSEK -> Int -> AdContent -> _
createAd account budget adContent = [...]

It's now impossible to call createAd without checking the currency of the account. If we test isAccountUsingSEK thoroughly, and ensure that there's no other public constructor of AccountUsingSEK, we have provided a lot of confidence in the system.

It's a small change, and it may seem trivial, but it greatly reduces risk. Some argue that it's costly in terms of performance and complexity. YMMV, but to me, on this project, it's a bargain given the importance of correctness.

The code above is pseudo-Haskell and the types have been simplified for clarity.