Separating Intent from Implementation with Domain Specific Languages in Typescript
I recently read through an amazing blog post by Matt Parsons titled What does Free buy us? and thought I’d see how close to his concept I could get with Typescript. (yes I’m aware gcanti has a full version of it)
So what is a Domain Specific Language (DSL)? In this case it’s a curated set of instructions that can be pieced together to make a program. More specifically, it’s a sum type of all possible operations that can be used to make a program.
The general idea is that we build a data structure with our DSL that describes a program, and then we write an interpreter of our DSL. There are several big wins here, but the biggest is that business logic is not tied to implementation details.
Let’s look at a very simple DSL to get a better intuition.
In this case our
Program type is either a
Read or a
Write. Typically we would think of read and write as functions that perform side effects, but that approach would force us to entangle our intent with our implementation. So what we’re actually doing is defining data structures that contain only the necessary bits of what is necessary to read and write. With this approach we can write an interpreter that works over Slack, a console app, SMS, twitter, etc.. But the best part is that all interpreters will work for all programs and vice versa. Any program we can write in our DSL can be interpreted by any interpreter of our DSL.
Building our DSL
Ok so we’re missing some things in our DSL. The first of which is that we need to fill in the
Write interfaces with what it means to read and write things. The second part is that we want to be able to chain
Write data structures together because we may want to ask a question and wait until we’ve asked the question to receive a response (this isn’t a political commentary show after all).
We can solve both of those in the same step if we use continuations and make our data structure recursive.
Read interface needs a
next property that is a function from
string -> Program which means that it’ll have a continuation (callback) whose argument is the value that was read. Our
Write interface needs a
valueToWrite property and the
Program to run. In other words,
Write is a command,
Read is a query. And because that would force us to always have a next program, I’ve also added a
Done case to our
Program so that we can encode when the program is finished.
Languages with sum types (discriminated unions) typically provide automatic constructors for the cases of a sum type. Typescript doesn’t, so our next step will be to create some functions that will allow us to create steps of our program.
There’s a limitation to this definition of our program though. We have no way to return a value from our program! That seems like a pretty big deal so let’s make one more refactoring to make our
There, now we’ve threaded the generic
A through all of our interfaces so that when we get to the
done case we have an
A that can be returned.
Writing our first program
So with these functions and our data structure we can now build our first program!
Fantastic, we have a data representation of how to greet someone within our DSL and we can easily see that the program will return a string when completed so that we can combine programs to make bigger programs. Unix philosophy FTW!
Interpreting our DSL
As I mentioned earlier, there are many different ways to interpret our program, but for the sake of being simple and perhaps a little boring, we’ll write a console interpreter.
Interpreters are basically a Natural Transformation from our
SomeOtherContext<A>. In our case we’re going to use promises so we’ll need to write a function from
So we’ve written a function that processes each case of our DSL and then recursively calls the
Program unless it receives a
Done instruction which has our ultimate
A stored in its
By creating a DSL of our problem domain and then defining our problem in terms of the DSL we are forced to write pure, functional representations of our business requirements. We can then take those same business requirements and interpret them in any way we’d like. As an added bonus, our interpreter will contain all of our external dependencies, so there’s no need for complex things like Dependency Injection or service passing into our business logic. Our interpreter is self sufficient and is just a function. But the best part is that all interpreters will work for all programs and vice versa. Any program we can write in our DSL can be interpreted by any interpreter of our DSL.