Type-safe Polymorphic Event Handling: Or, Making Illegal Argument Combinations Unrepresentable

Posted on March 7, 2018

Motivation

Suppose you’re writing bindings to a node library that has some classes which can emit events. You may have several FFI definitions that look like this:

Let’s say you have onError and onSuccess, but their callbacks look different (maybe they have different arities or take different argument types). You could just expose those two methods for handling events, and that wouldn’t be so bad. But if you find out you need to expose many more, you may want to find a DRYer approach than having a bunch of onEvent functions.

Suppose you’ve cleaned up your FFI so that instead of exporting a bunch of event handling functions, you have one catchall:

Unfortunately we can’t safely use this from Purescript – if we want type safety we’ll still need to export a bunch of functions, like onError = unsafeOn "error".

So, the goal is to have a single on combinator that somehow takes an event type, object type, callback type and combines them in the right way.

First attempt

Let’s try enumerating all our events.

Then we might try something like this:

The problem with this is that we can pass literally any value as a callback, so this won’t do.

Second attempt

Odds are we know exactly what the types of our callbacks are, so we’ll just enumerate those too – at most one callback constructor per Event constructor.

This is terrible! We’re silently failing if the wrong callback type is associated with the wrong event, which is surprising to say the least.

Thus we also want to be able to rule out illegal argument combinations and in a way that’s transparent to the caller.

Third attempt

Good for us that there’s a standard way of dealing with the possibility of failure (defined in our case as passing a bad combination of arguments to the on2 function).

But this is not ideal. While it solves the problem of making failure explicit, it pushes validation to runtime, and this problem definitely feels like something that can be prevented at compilation.

And let’s be honest, odds are this would be used by pattern-matching on Nothing and handling that with pure unit – so, the same as the on2 definition but with more misdirection.

Fourth attempt

Now we know that we want the event to somehow determine the type of the callback. This suggests we should use a typeclass: if we can somehow exploit the lack of an instance to mean that an event is given the wrong callback type, we’ve succeeded.

Nope: Could not match type ( le :: LIB_EFFECT | e0 ) with type ( le :: LIB_EFFECT | e01 )

The problem here is that the e in the callback type is not actually the same as the e in the result type.

Fifth attempt

Instead of hiding the e behind a quantifier in the class method, let’s factor out the whole result type.

Success! This finally compiles. But why stop here when we can go type-crazy?

Fifth attempt, alternate

Usage

Now we can turn this:

into this:

But note that the following wo(uld)n’t compile:

This is because using success tells the compiler the callback must be a success callback – whether that means (as is defined in the instance) that it takes multiple arguments, or that its one argument isn’t an Eff.Error. Also, the use of message tells the compiler that the callback’s argument is an Eff.Error.

Or you may prefer the original Attempt 5 way:

¯\_(ツ)_/¯

Conclusion

As presented, there should be one Event for each instance. But sometimes you may want to use the same event for two different objects. In that case, the definition of On can be changed as follows:

Now you can use the same Event for two different objects instead of defining e.g. SuccessObj1 and SuccessObj2.