ADTs in OOP - FTW or WTF?
Nate Clark
Posted on January 18, 2020
A lot of times it might feel like some development communities are far apart. This is totally understandable. We are all doing our best, and we don't have the time or energy to stay up to date with every corner of the development world. In this post I want to bring the typed functional world and the object oriented world a little closer together. To do so, let's take a small snippet adapted from the ReasonML documentation and dissect it through porting to TypeScript. You can play with this snippet on the try reason website.
type account =
| None
| Instagram(string)
| Facebook(string, int);
let greeting = (account) =>
switch (account) {
| None =>
"Hi!"
| Facebook(name, age) =>
"Hi " ++ name ++ ", you're " ++ string_of_int(age) ++ " years old."
| Instagram(name) =>
"Hello " ++ name ++ "!"
};
let myAccount = Instagram("nate")
let myGreeting = greeting(myAccount)
Js.log(myGreeting)
What is Reason Doing Here
I think there are 2 big ideas required to understand this code: variants, and pattern matching.
It is worth mentioning that some communities call variants by another name. You might hear terms like "Algebraic Data Type", "Sum Type", or "Union Type". These are all roughly the same thing as Reason's variant.
The account
Variant
type account =
| None
| Instagram(string)
| Facebook(string, int);
This code introduces one new type, account
, and three ways to construct a value of that type, None
, Instagram
, and Facebook
. The None
constructor doesn't require an data. The Instagram
constructor requires a string
. The Facebook
constructor requires a string
and an int
. Hmmm... new type... constructors... Let's eagerly jump to an object oriented version of this code.
class Account {
name?: string;
age?: number;
type: string;
private constructor(name?: string, age?: number, type: string) {
this.name = name;
this.age = age;
this.type = type;
}
static None() {
return new this(undefined, undefined, "None");
}
static Instagram(name: string) {
return new this(name, undefined, "Instagram");
}
static Facebook(name: string, age: number) {
return new this(name, age, "Facebook");
}
}
- Account type introduced: ✔
- Can construct a
None
account with no data: ✔ - Can construct an
Instagram
account with a name: ✔ - Can construct a
Facebook
account with a name and age: ✔
This is looking fair enough so far. As far as representing the Reason code goes, we're well on our way. Now let's take a look at that greeting
function.
Pattern Matching in greeting
let greeting = (account) =>
switch (account) {
| None =>
"Hi!"
| Facebook(name, age) =>
"Hi " ++ name ++ ", you're " ++ string_of_int(age) ++ " years old."
| Instagram(name) =>
"Hello " ++ name ++ "!"
};
The heart of this code is branching based on the type of the account.
- If it is a
None
account, we simply greet with"Hi!"
. - If it is a
Facebook
account, we greet based on the name and age. - If it is an
Instagram
account, we greet based on the name.
This sounds like a pretty mechanical translation as well. And since Reason co-opted switch
for pattern matching, we can even just use a regular JavaScript switch
. However, instead adding a greeting
function, let's make it a method on Account
.
class Account {
// ... our Account definition from before ...
greeting(): string {
switch (this.type) {
case "None":
return "Hi!";
case "Facebook":
return `Hi ${this.name}, you're ${this.age} years old.`;
case "Instagram":
return `Hello ${this.name}!`;
}
}
}
So, at this point we are almost finished. Let's wrap this up with console.log
.
// original reason code in comment above each line
// let myAccount = Instagram("nate");
let myAccount = Account.Instagram("Nate");
// let myGreeting = greeting(myAccount);
let myGreeting = myAccount.greeting();
// Js.log(myGreeting);
console.log(myGreeting);
Not Quite
You might be screaming at me right now. I know that typescript is screaming at me right now:
Function lacks ending return statement and return type does not include 'undefined'.
- The TypeScript Compiler
Our problem is familiar to anyone who has done typed programming. Our method promises to return a string, but we did not make good on that promise. If this.type
is something other than "None"
, "Instagram"
, or "Facebook"
, our code will happily return undefined
. We could return some dummy or throw an exception, but I think we are missing out on some connection between algebraic data types and object oriented programming. Let's take a step back.
Another Way
Our first red flag should have been that we were branching based on some sort of type information. Object oriented programming languages have a very special tool for branching based off type information. It is called method dispatch. We should not write the branch, it is already built into the language. We just have to model our problem appropriately. If we are going to do method dispatch, we want greeting
to go to the right place. Instead of writing the branch ourselves, we need three different greeting
methods for the language to choose from. If we want our Account
type to look the same from the outside, we will have to create three subclasses. A couple nice things will fall out of this. Let's look.
interface Account {
greeting(): string;
}
class None implements Account {
greeting(): string {
return "Hi!";
}
}
class Instagram implements Account {
name: string;
constructor(name: string) {
this.name = name;
}
greeting(): string {
return `Hello ${this.name}!`;
}
}
class Facebook implements Account {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
greeting(): string {
return `Hi ${this.name}, you're ${this.age} years old.`;
}
}
Let's break down what happened.
We no longer have an Account
class, instead we have an Account
interface. This makes a lot of sense. The reason account
type was really just a label for either the None
, Instagram
, and Facebook
account data structures. If we never pattern matched on the account to pull out the underlying data, it was just a black box. Once we pattern matched, we knew that we were dealing with one of those structures, and we could actually do work.
We also got rid of those ?
s! This is a great sign because name
and age
are not truly optional. If the account is a Facebook
account, they are always required. By making each variant of account
into its own class, we get to model the data required by each variant on its own. Then we use implements Account
to tie them all back together.
ADT -> OOP
So we have a bit of a formula here. It is a simple one, and it will need some more work once you use more interesting forms of pattern matching. However, I think it provides a lot of intuition.
- You can think about each variant/algebraic data type/union as an
interface
. - Each constructor is a class that implements that interface.
- If a function simply pattern matches on the constructor type, it is a method on the interface.
In this example:
- We turned
type account
intointerface Account
- We turned
None
,Instagram
, andFacebook
into classes thatimplement Account
- We turned
greeting
into a method on theAccount
interface.
Learning More
These two courses really cemented a lot of this comparative stuff for me. I think they are pretty great, and I highly recommend them.
Thanks
Hopefully this article has helped you cross that boundary between statically typed, functional languages and class based, object oriented languages a little smoother.
Thanks for reading!
Posted on January 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.