Using variant types in ReScript to represent business logic
Josh Derocher-Vlk
Posted on October 15, 2023
Types can be great at creating documentation to know what parameters are used in function, what a function returns, and to stop us from accidentally omitting required object keys.
In TypeScript most of the types we use daily are primitive types like string
or number
. We also uses types (or interfaces) to structure an object correctly like type Person = { name?: string, last?: string }
. It gives all the developers working on a project a shared understanding of what a Person
is and what values it requires and which values are optional. The TypeScript compiler helps us write code that adheres to this shared understanding.
In ReScript we gain access to something that TypeScript doesn't have, variant types. A variant allows us to represent data as "this or that", and each variation can contain unique information. Combine this with ReScript's pattern matching and you have a powerful tool to represent business logic with types.
The feature we have to build
We have an ask from our product team to greet customers who visit a new landing page. The greeting we show will be different depending on what we know about the customer.
- If we don't know the customer's name we want to show "Hello stranger!"
- If we only know their first name we want to show "Hi FIRST_NAME!"
- If we only know their last name we want to show "Hello to the LAST_NAME family!"
- If we know the first and last name we want to show "Hello FIRST_NAME LAST_NAME"
Building it in TypeScript
Before we dig into how variant types and pattern matching can make this easy let's take a look at how you would implement this in TypeScript.
Here's how we represent our person in TypeScript:
type Person = {
name?: {
first?: string
last?: string
}
}
And here's the code to handle our greeting logic:
function greet(person: Person) {
if (person.name && person.name.last && person.name.first) {
// first and last name
return `Hello ${person.name.first} ${person.name.last}!`
} else if (person.name && person.name.last) {
// last name only
return `Hello to the ${person.name.last} family!`
} else if (person.name && person.name.first) {
// first name only
return `Hi ${person.name.first}!`
} else {
// no name
return "Hello stranger!"
}
}
This meets our requirements and it works as expected, but it can be a little confusing to read even with a few comments. This solution also isn't portable since we have to check for each value like this any time we want to do something with the person's name.
Building it in ReScript
With variant types we can take a step back and change how we represent our data. In our TypeScript example we created a base Person
type that shows exactly the keys it could have and what types those values are. It doesn't tell us anything about why this type matters to our application or business logic. The type and the logic are different things that are defined in different places.
Types with logic
Based on the requirements we have for the feature we can create a variant type that will represent each possible state.
type person =
| NoName
| FirstName(string)
| LastName(string)
| FirstAndLastName(string, string)
We then use pattern matching to handle each possibility:
let greet = person =>
switch person {
| FirstAndLastName(first, last) => `Hello ${first} ${last}!`
| FirstName(first) => `Hi ${first}`
| LastName(last) => `Hello to the ${last} family!`
| NoName => "Hello stranger!"
}
Even without comments or an understanding of anything about our requirements it's fairly easy to grok what's going on here.
You can see this example in the ReScript Playground.
ReScript's compiler will also warn us about missing cases.
Why would I do this?
Variant types add meaning to our data
When you have a lot of developers working on features and functionality it's easy to lose track of the expected business logic as an application grows. A type that is a simple object doesn't carry any meaning with it and it's left up to every developer to handle how to read that data and to implement the required logic for every feature. You also need to know about that logic if you are trying to read these if/else
statements to understand what is going on.
Variant types solve this by allowing us to represent our data in a way that carries meaning.
Adding new cases is easy
A few months go by and we need to add support for greeting a person when we only know the city they live in.
We have to add a new case to our variant and rename NoName
to NoInfo
.
type person =
| NoInfo
| FirstName(string)
| LastName(string)
| FirstAndLastName(string, string)
| City(string)
The compiler will error out on NoName
and we can quickly fix that. It will then warn us about the missing case so we can go ahead and add that in. Adding { city?: string }
to our TypeScript example doesn't do anything to show us where that code is used or where we need to handle this new requirement.
Wrapping up
Using variant types to represent our data allows us to quickly add or change business logic with confidence. The compiler will warn us for missing cases, and will guide us to places that need to change if we remove or rename an existing case.
This is an excellent talk on domain modelling with types by Scott Wlaschin. He's using F# so it's slightly different, but F# and ReScript have a common ancestor in OCaml so the concepts apply to both all similar ML family languages.
If you interested in learning more about ReScript take a look at these other articles I have written.
Posted on October 15, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.