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 caseof 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 of Maybe<A>. Basically, if _value is null, it’s treated as Nothing, otherwise, it’s treated as Just. Also, constructor is private and you can create Just or Nothing values using the static functions Just and Nothing.
  • The match method takes an object of cases and calls the appropriate case by checking this._value.
  • The then method is the standard monadic bind function. It matches over the current Maybe and if it’s Just, it calls the argument function with this.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 objects. 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 Maybes.

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) as Nothing because we’re comparing the value with null in the then method. If you’re implementing it for anything serious, you’d probably want another private member which decides whether the value is Nothing or Just.
  • 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 the assign method. Calling this on a Maybe<T> where T is not an object fill fail at runtime. Always start with Maybe.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 thens pretty well.