Typeclasses in Typescript
A guide to using and building typeclasses in Typescript with fp-ts
javascripttypescripttypeclassesmonadfunctional programmingfp-tsIn statically typed functional programming, typeclasses are often used in place of interfaces to achieve polymorphism (that is, writing code that works for multiple different types of values). Fantasy-land is a JS standard for algebraic structures, which mostly employs interfaces to achieve reuse. Fp-ts is a popular library that contains the same algebraic structures from fantasy-land, except it's built on typeclasses. Typeclasses are much more extensible and lend themselves to certain type structures that would otherwise be complex to model with interfaces, at the cost of being a bit more verbose & clunky at times.
For example, let's say we wanted to build a function that takes a value, turns it into a string, and logs it. Let's suppose we can't just call JSON.stringify
, and we need each object to be able to specify its own logic to achieve stringification.
Using an interface, this would look roughly like:
interface Printable {
print(): string
}
function logValue(value: Printable) {
console.log(value.print())
}
This works fine, but comes with a few limitations. First, it can only be used with values from modules that depend on the Printable
interface. For instance, in order to use it with a string
value, you'd need to wrap it in an adapter object:
const name = "Paul"
logValue(new StringPrintable(name))
This isn't too bad, but suppose we needed the functionality of two interfaces, we'd need a special adapter which implements both of those interfaces. The problem gets worse as we add interfaces.
const name = "Paul"
logValue(new StringPrintableAndComparable(name))
In Typescript, we encode typeclasses with interfaces, but we separate the value from the method it's implementing. Instead of implementing that interface with our original value, we create a separate value and have that second value implement the interface. Whenever we need to write a function for some value that "implements" the typeclass, we just have our function take two values; the original value, and an instance of a typeclass for that value.
For example, let's build the same logValue
function with a typeclass. Our Printable
interface becomes a typeclass, and it needs to work for any value, so we introduce a type parameter, A
(The type is irrelevant to the definition of Printable
, so we keep it concise). Our print
function turns from a no-arg function, into a function that takes the value, A
, and returns a string.
type Printable<A> = {
print(value: A): string
}
Our logValue
changes to take two values; the original value that we're converting, and an instance of the Printable
typeclass for the original value (which will hold the logic for converting). We can use the print
method on Printable
instance, passing our original value in:
function logValue<T>(t: T, stringable: Printable<T>) {
console.log(stringable.print(t))
}
Here, logValue
works for any value. We don't need to wrap the value in an adapter layer, and adding capabilities simply consists of adding typeclass parameters:
declare function logValue<T>(t: T, stringable: Printable<T>, equal: Equal<T>)
Let's take a closer look at that Equal
typeclass. In plain Javascript, it's sometimes useful to compare two values. Oftentimes libraries take the shortcut of using the strict equality comparison algorithm in times that it needs to determine if two values are equal. For example, Set
s in Javascript determine equality using this algorithm. By default, a React PureComponent
will compare props based on strict equality. The reselect
library also uses strict equality to determine if parameters are the same. Sometimes this is fine, but other times it's useful to have finer-grained control over how two values are determined to be "equal."
If we were to build this as an interface, it might look something like this:
interface Equal {
equals(other: Equal): boolean
}
and then a few implementations:
class User implements Equal {
constructor(private id: number) {}
equals(user: User) {
return user.id === this.id
}
}
class Point implements Equal {
constructor(private x: number, private y: number) {}
equals(point: Point) {
return this.x === point.x && this.y === point.y
}
}
However this isn't totally accurate, For example, I can have two different instances of Equal
s, but I can still compare them, while satisfying the typechecker:
const user: Equal = new User(1)
const point: Equal = new Point(15, 30)
user.equals(point) // type-checks, but is logically unsound
To make this more type-safe, we can use a typeclass to model the Equal
interface. Instead of building a method that takes a single parameter, we build a method that takes two parameters; the original value to operate on, and an additional parameter to compare it to:
type Equal<A> = {
equals(left: A, right: A)
}
Since the two parameters are fixed on a single type, there's no chance to mess this up. Using this new typeclass, let's build a function that takes in an array of values and removes the duplicates. You may be tempted to just create a Set
from the array, but Set
uses strict equality checking, which may lead to surprising results:
const users = [{
id: 1, name: "Bob"
}, {
id: 2, name: "Susan"
}, {
id: 1, name: "Bob"
}]
console.log(new Set(users))
This logs:
Set {
{ id: 1, name: 'Bob' },
{ id: 2, name: 'Susan' },
{ id: 1, name: 'Bob' } }
Oops! We wanted to remove duplicates, but Set
can only make decisions based on referential equality.
Let's write a function using our Equal
typeclass which implements this functionality correctly:
In order to use this with User
values, we simply need to construct an instance of Equal<User>
:
/** Two users are equal if their ids are equal */
const userEq: Equal<User> = {
equals(l, r) {
return l.id === r.id
}
}
And then use it when invoking removeDupes
:
const users = [{
id: 1, name: "Bob"
}, {
id: 2, name: "Susan"
}, {
id: 1, name: "Bob"
}]
console.log(removeDupes(users, userEq))
This logs:
[ { id: 1, name: 'Bob' }, { id: 2, name: 'Susan' } ]
which is much closer to what we'd expect.
Thinking in typeclasses
Using typeclasses brings a slight shift of thinking when building polymorphic functions. Instead of restricting parameters to all subclasses of an interface, you just introduce a type parameter, and then also add a typeclass for the needed functionality, for example:
Instead of working for only subclasses of Talker
:
function speak(talker: Talker): string { ... }
typeclasses allow any values that have an instance of the Talker
typeclass:
function speak<T>(talker: T, tc: Talker<T>): string { ... }
When using libraries that are built on typeclasses, instead of thinking, "Does this value implement the X interface," it's more useful to think "Is there an instance of typeclass X for this value?"
Creating typeclass instances
The Equal
typeclass we just wrote exists in fp-ts as Eq
. Fp-ts also includes the removeDupes
function we wrote, named as uniq
(in its Array
module). Let's get a feel for using the fp-ts library by using the fp-ts equivalents of the functions we built.
Creating an instance of Eq
is as simple as building a value that "implements" the Eq
interface. Let's suppose we had a type that models coordinates on a 2d plane as tuples (an X value and a Y value), and we had a list of them:
type Point = [number, number]
const points: Point[] = [
[1, 2],
[6, 7],
[1, 2]
]
Let's say we wanted to remove each duplicate value before processing them further. We'll accomplish this in two steps:
- Build an instance of the
Eq
typeclass forPoint
s. - Use that
Eq
instance with theuniq
function.
All we need to do make the Eq
instance is to build a value whose type is Eq<Point>
. There's just one method we have to implement, and that's equal(a: Point, b: Point) => boolean
:
import { Eq } from 'fp-ts/lib/Eq'
const pointEq: Eq<Point> = {
equals(a: Point, b: Point) {
// ???
}
}
what should the implementation of equals
look like?
The uniq
function (unlike our removeDupes
function) is curried, and takes the Eq
instance first:
function uniq<A>(E: Eq<A>): (as: Array<A>) => Array<A>
It then returns a function that takes an array of those values, and returns a filtered version of that array, where equality between elements is determined by the Eq
instance.
import { uniq } from 'fp-ts/lib/Array'
const filterPoints = uniq(pointEq) // returns a function that will filter an array
filterPoints(points) // returns a filtered list of points
Composing typeclasses
Constantly implementing typeclasses for straightforward values like combinations of numbers can quickly become cumbersome. Fp-ts provides useful utilities for building complex typeclasses by composing smaller ones. For instance, there is already an Eq
instance which compares numbers, and there is already a function which builds a Eq
instance for tuples that contain any value. Let's use these utilities to compose an Eq
instance for Point
s, rather than handcrafting our own.
import { Eq, getTupleEq, eqNumber } from 'fp-ts/lib/Eq'
const pointEq: Eq<Point> = getTupleEq(eqNumber, eqNumber)
The Monoid typeclass
The Monoid
typeclass from fp-ts abstracts over how to combine two values.
It contains just two members:
interface Monoid<A> {
empty: A;
concat: (x: A, y: A) => A;
}
concat
encapsulates the logic to combine the values, and empty
is a value of the same type that represents a 'zero' value. We can make an instance of Monoid<number>
to represent combining two numbers with addition:
const addition: Monoid<number> = {
empty: 0,
concat: (a, b) => a + b
}
addition.concat(4, 8) // 12
What can we do with this now? Let's take a look at a useful method from fp-ts, foldMap.
Let's introduce this method with a thought experiment. Let's suppose you have a list of apples and you have way to combine two apples into one apple. Given these two things, you should be able to take that list and "squash" it into one, taking each apple and combining it with the previous one.
Let's also suppose that you have a list of "bananas" and a way to combine two oranges into one orange. What would you need to combine the list of bananas into a single orange? If you had a way to turn bananas into oranges, you could iterate over the bananas, turning each one into an orange, and then combine them once they're in their orange state.
This is the pattern behind foldMap
, except it's generalized, meaning it works for a list of any kind of contents, A
, and any kind of combination method for B
(Monoid<B>
), as long as we have a way to turn those A
's into B
's.
Here's the signature for foldMap
:
foldMap: <B>(M: Monoid<B>) => <A>(f: (a: A) => B) => (fa: A[]) => B
It can be broken up into three different parts, let's take it one section at a time:
<B>(M: Monoid<B>)
, which returns a: <A>(f: (a: A) => B)
, which returns a (fa: A[]) => B
This function allows us to take an array of values of type A
, a monoid for values of type B
, and a function which turns A
's into B
's, and finally, returns a single B
. Phew that was a mouthful, let's use this to count all the login counts across some users.
import { Monoid } from "fp-ts/lib/Monoid";
import { foldMap } from "fp-ts/lib/Array";
type User = {name: string, logins: number}
const users: User[] = [
{ name: "Paul", logins: 10 },
{ name: "Sue", logins: 7 },
{ name: "Bob", logins: 8 }
];
foldMap(addition)((u: User) => u.logins)(users); // 25
foldMap
allows us to combine values by transforming them into something our Monoid
can combine. If there are no items in the list, then foldMap
simply takes the zero
value from the Monoid
.
This is similar to sumBy
in lodash:
_.sumBy(users, u => u.logins)
The difference here is that we can use any Monoid to combine these values, not just the addition Monoid! What if we wanted to compute whether or not all of the users in our list are frequent users? We'd want to reduce the array to true
if all of the users have logged in at least five times, and false if one of them have not.
First, we'd want to map each user to a boolean
(true
if they have logged in five time, false
otherwise), then we'd want to combine those boolean
values with a monoid that operates on boolean values.
declare const andMonoid: Monoid<Boolean>;
const userIsFrequent = (u: User) => u.logins > 4
foldMap(andMonoid)(userIsFrequent)(users); // true
what does the implementation of andMonoid
need to be in order to facilitate this? Try implementing this on your own and test the results with various arrays of users.
as another exercise, try to implement foldMap
:
const foldMap: <B>(M: Monoid<B>) => <A>(f: (a: A) => B) => (as: A[]) => B =
M => fab => as => {
// M.concat combines two b's
// M.empty is the "empty" state of 'b'
// fab converts an 'a' into a 'b'
// as is an array of 'a's
}