Pipelines in TypeScript with Generics

Pipelines in TypeScript with Generics
Photo by Quinten de Graaf / Unsplash

I first encountered a "Pipeline" in code when working with Laravel a few jobs ago. Not long afterwards, I started learning Elixir in my spare time, which had a similar concept with its Pipe Operator. And of course, if you're familiar with Bash you'll know about piping output into other functions using the pipe |.

At the time, I really liked the expressive API that Laravel had created, allowing you to easily take an initial value, perform discrete operations on it and then spit it out or do something with it at the end.

I'd forgotten all about this feature, up until a few weeks ago when I wanted something similar in TypeScript. For example:

pipe(5)
  .throughFunctions(
    n => n + 2,
    n => n * 2
  )
  .return(); // 14

Of course, this example is just with a simple number and some predefined functions. But, imagine you were dynamically gathering a list of operations based on something:

const operations: ((num: number) => number)[] =
  gatherOperations();

pipe(5).throughFunctions(...operations).return()

Or maybe you're writing a small web framework, and wanted to pipe a request object through some middleware:

const IsAdminMiddleware = (request: Req) => {
    if (!request.body.isAdmin) {
        throw new AuthorizationError("Request is not from an Admin")
    }

    return request;
}

pipe(request)
  .throughFunctions(IsAdminMiddleware)
  .then((request) => {
    console.log(`Request ${request.id} processed successfully`)
  });

Hopefully you can see how this pattern might be useful to implement in your own code! Let's dig in and create a basic Pipeline.


Note: The Pipeline class we are creating will be limited to accepting and returning values of the same type. For example, once a pipeline is initialised with a number, only functions that accept and return a number can be used. Of course, there's nothing stopping you from expanding on this code to allow the type of the piped value to change as it progresses through the pipeline, but it's out of scope for this post.

Creating the Pipeline Class

Under the hood, the pipe method we used above just creates a new instance of a Pipeline class. Let's create this. We're using generics here to enable some type guarantees when passing functions to our pipeline.

class Pipeline<T> {
  constructor(private param: T) {}
}

new Pipeline(5);

new Pipeline<string>("Hello");

And then let's create the shorthand pipe function:

function pipe<T>(value: T): Pipeline<T> {
  return new Pipeline<T>(value);
}

Creating the throughFunctions method

We want to be able to pipe this data through a list of functions, but we also want type safety around these functions, so that we can catch more errors when we are developing.

For example, imagine if we didn't have type safety:

pipe("hello").throughFunctions(
  // What happens when you multiply "hello" by 2?
  // You get a NaN, that's what.
  n => n * 2
).return();

So, we want our throughFunctions method to use the type that we defined when creating our Pipeline class:

class Pipeline<T> {
    // ...
    
    throughFunctions(...funcs: ((param: T) => T)[]): Pipeline<T> {
        funcs.forEach(func => {
            this.param = func(this.param);
        });

        return this;
    }
}

Unpacking this, you'll see this is a variadic method, meaning we can pass in one or more of the value for funcs. The type of the funcs parameter is (param: T) => T)[] — this means an array ([]) of functions that have one parameter of type T that return the type T. For example, our n => n + 2 function in the introduction.

You should also note that the function returns the type Pipeline<T> — this is the type of the current class! This allows us to chain together multiple methods, which you'll often find is called a fluent interface or method chaining.

Inside the function is pretty straight forward. We're looping through each of the functions that have been passed in and are executing them, passing in the current value and reassigning it to the result.

At the end of the throughFunctions method, we're returning this to satisfy the Pipeline<T> return value.

Creating a return method

Phew! Almost done. Our Pipeline can now pass our data through as many functions as it wishes. Now all we need is a way to unpack the value contained inside it.

Let's create a return method on our class. (I wasn't sure if creating a method called "return" was valid, but it seems to work without issue!)

class Pipeline<T> {
    // ...

    return(): T {
        return this.param;
    }
}

That's it! We're saying that the return type will be T, which will match the type of the initial value we passed in. Then, we just return the private property param, which we defined back in the Pipeline class's constructor.


Conclusion

That should just about do it!

As I mentioned earlier, this is a fairly basic implementation of the Pipeline pattern, but has suited my needs so far. If you're tempted to push this further, why not try:

  • Allowing objects to be passed in via a throughObjects method. These objects would need to implement an interface (maybe have a handle method: throughObjects(...objs: { handle(value: T): T }[]): Pipeline<T>) to ensure that they could be called successfully.
  • Allowing the type to be mutated as the Pipeline progressed. This would complicate the Pipeline a little, but would make it way more powerful. For example, starting with a number, and allowing further steps to turn this into an array of numbers, then an object that contains that array.

Photo by Quinten de Graaf on Unsplash