Simulating Haskell’s do notation in Typescript
Haskell has convenient syntax for monads called “do notation” that is useful for flattening out nested Monadic binds (sort of equivalent to .then
method in Javascript/Typescript). If you don’t know what monadic means, it will become clear in the following sections. If you’re familiar with async/await
, it is a somewhat more powerful version of the do
notation with one limitation. It is restricted to the built in Promise
type. Promise
happens to be a monad because it has the .then
method. Unfortunately, async/await
is a missed opportunity IMO because monads come in all shapes and sizes. It would be helpful to have some way to flatten out nested calls to the .then
method for any monadic type. Well, there is (sort of). Keep reading.
Motivation
You may skip this section if you’re already familiar with Haskell do
notation/Scala’s for/yield
/F# computation builders.
A simple type built into Haskell is the Maybe
type (which is also a Monad). It represents an optional value which can either be a value or nothing. The only way to get the value out of it is to pattern match (which you can think of as a more powerful if/else) over it so, you are forced to handle cases when the result is nothing. For example,
Here, someFailableComputation can maybe return an Int value, or return Nothing.
When we call the function in main function, we are pattern matching over the result of the computation (which is the case
… of
thingy). When it succeeds, (Just x), we bind the result to the variable x and print (x + 1). Otherwise we print “Failed”. Simple. Type information flows through so the type of x
is known to be Int at compile time.
Now, what if we have multiple computations that we need to run in sequence that use the results of the previous computations (for example, a series of network calls)? We can do it the usual way by pattern matching.
As you can see, we have nested pattern matches here. It sort of looks okay here, but if we have more computations that can fail, it can get ugly soon. Fortunately, using do notation, we can set up a function that “short circuits” the whole thing in case of a Nothing
being returned in the middle.
See, much better. The control flow is more obvious and the boilerplate of pattern matching has been taken care of by the syntax. What would have been a series of nested pattern matches is now flattened out vertically.
If this looks familiar to async/await
, it is. For example, look at this piece of code in Javascript.
Now that looks callback hellish. I probably missed out some parenthesis so don’t look too hard at it. You get the idea.
Rewriting it using async/await gives us this.
Much better.
Despite the async/await version being superior in one way (closer to the regular syntax of the language; can be used in if statements, loops, etc) to the Haskell version, which restricts the <-
binds (similar to await
in the JS version) to do
blocks, it is more restricted in another sense that only Promises currently support async/await
. Still, both help in flattening out nested calls to then
or >>=
in Haskell. Can we have something in Typescript that flattens out .then
calls for arbitrary monads without losing type safety?
The answer is yes.
My solution
I have used Typescript’s mapped properties to simulate a “scope” of variable assignments which can be chained using method syntax.
Defining the Maybe class
Lets start by defining a Maybe class in Typescript. (Install typescript beforehand by doing sudo npm install -g typescript
first). Create a file Maybe.ts.
Things to note in the Maybe class.
- The constructor takes a value of type
A | null
and creates a new value ofMaybe<A>
. Basically, if _value isnull
, it’s treated asNothing
, otherwise, it’s treated asJust
. Also, constructor is private and you can create Just or Nothing values using the static functionsJust
andNothing
. - The
match
method takes an object of cases and calls the appropriate case by checkingthis._value
. - The
then
method is the standard monadic bind function. It matches over the currentMaybe
and if it’sJust
, it calls the argument function withthis.value
otherwise, returns a Nothing.
If you run this script by running tsc && node Maybe.js
in the directory where you created the file, you should get the following output
Got Just(23)
Got Nothing
The actual trick
Now, here’s a method that you can add to the class that will make flattening possible.
The most interesting thing about this method is the type signature. It uses some relatively advanced Typescript features (intersection types, union types, mapped types). Mapped types is the one feature that’s not present in most other languages that allows the function to be well typed.
Before looking more closely into the guts of the type signature, let’s look at how it will make our code look.
console.log(
Maybe.Just({})
.assign("x", positive(23))
.assign("y", positive(23))
.assign("z", (scope) => positive(scope.x - scope.y))
.then(scope => Maybe.Just(scope.z))
.match({
Just:result => `Just(${result})`,
Nothing:() => "Nothing"
})
) // Just(0)console.log(
Maybe.Just({})
.assign("x", positive(23))
.assign("y", positive(25))
.assign("z", (scope) => positive(scope.x - scope.y))
.then(scope => Maybe.Just(scope.z))
.match({
Just:result => `Just(${result})`,
Nothing:() => "Nothing"
})
) // Nothing
As you see, we start our chain with a Maybe.Just({})
value, which will keep our “scope” inside it. Each call to .assign
will attach a new value to our scope object with the given key. Thanks to Typescript’s mapped types, our scope object at each step will contain the keys assigned by the previous steps so the compiler knows that result
in the final step is a number
.
Pretty cool if you ask me but then I’m biased ;)
Now, lets break down the type signature of the .assign
method.
assign<K extends string, B>(
k: K, other: (Maybe<B> | ((a: A) => Maybe<B>))
): Maybe<A & {[k in K]: B}>
This method takes two generic parameters, K
and B
. K
should be a subtype of string
because we will be attaching it to an object. K
is the type of the key you supply and B
is the type of the return value of the Maybe
you’re using to do the assignment. For ease of use, the other
parameter can either be a Maybe<B>
or a function from the current type A
to Maybe<B>
.
The return type is Maybe<A & {[k in K]: B}>
. It simply means that the result will be a Maybe
of the current Maybe
's contained value with an additional field K
which will contain a value of type B
. So, if you have a Maybe<{x: number}>
, and you call .assign("y", Maybe.Just("asdf"))
on it, the resulting type will be Maybe<{x: number; y: string}>
. We need the type of the key to be generic K
for type safety to be maintained. If we had used string
as the type of the k
parameter, it wouldn’t work. Basically, typescript can narrow down string types to string literal types so when you use a generic parameter K extends string
, the .assign("y", ...)
will infer the type K
to be "y"
as opposed to string
.
There’s hardly anything interesting going on in the body of the assign
method. It calls .then
on the Maybe
values. It also checks if the second argument is an instance of Maybe
or if it’s a function. If it’s a function, then it calls it with the current Maybe
's value to get a new Maybe
. Then it simply calls .then
on it. If you stare at it for long enough, you should be able to figure it out. Note that when calling Object.assign
to attach the new result to the scope, I’ve put a cast to any
type. This is because Object.assign
requires it’s arguments to be object
s. This means calling assign
on a Maybe
that contains a non object type, it will fail at runtime. We can actually put a bound on the class parameter A
by A extends object
but then we wouldn’t be able to create Maybe<number>, Maybe<string>
, etc. Just make sure that you only call .assign
on object type Maybe
s.
For other monads
.assign
method only uses .then
method so you can attach it to any type that has a then
method with the correct type. Unfortunately, Typescript lacks Higher-Kinded types so it’s not possible to write a generic assign
method for any type in a type safe manner. But because it will only be defined once per monad, you can ignore type safety in this case and just copy and paste the definition for your type, replacing YourType
with whatever your actual type is as long as YourType
has .then
and .of
methods of the correct types.
public assign<K extends string, B>(
k: K, other: (YourType<B> | ((a: A) => YourType<B>))
): YourType<A & {[k in K]: B}> {
return this.then(
a => {
const m = other instanceof YourType
? other : other(a);
return m.then(
// define a static method `of`
// for YourType which takes an A
// and returns a YourType<A> b => YourType.of(
Object.assign(
{}, a, {[k.toString()]: b}
) as any
)
)
}
);
}
You can even make a function that takes YourType
as a parameter and returns the body of this method so you don’t have to copy and paste. It won’t be type safe of course but you can trust the implementation is correct because it’s pretty short.
Caveats
- I haven’t tried this out in anything serious so I don’t know if it will be as elegant in more complex scenarios even though I don’t see why not.
- Be careful with scoping. If you have multiple assigns to the same key in a single chain, the most recent one will be used (which is what you’ll probably expect). Also, don’t assign values of different types to same keys in a single chain because the type of that key would be inferred as a union of all the assignments.
- The Maybe type we implemented here will treat
Maybe.Just(null)
asNothing
because we’re comparing the value withnull
in thethen
method. If you’re implementing it for anything serious, you’d probably want another private member which decides whether the value isNothing
orJust
. - Monads don’t compose so obviously you can’t use another monadic type (something like
Result<T, E>
) value for assigning to a chain of monadic values of another type. This is apparent in the type signature of.assign
method. - As I pointed out earlier, there’s a cast to
any
in theassign
method. Calling this on aMaybe<T>
whereT
is not an object fill fail at runtime. Always start withMaybe.Just({})
to start with an empty scope. Also, don’t assign to types you care about, that may lead to unexpected results. Start with empty scopes.
Conclusion
While this post only showed one of the simplest monads (Maybe) but you can go crazy with your custom monads. Want to have an implicit environment (Reader monad), accumulate values (Writer monad), keep implicit state (State monad), do error handling (Error monad) or a combination of all, roll your own monad type! This is actually pretty useful if you’re writing a compiler or something where you need to keep some state (State), have a scoped type environment (Reader), collect type errors (Writer) and do error handling (Error).
While this may not be as elegant as async/await
, Haskell do
, Scala for
, etc, it still does the job of flattening out nested then
s pretty well.