shaky.sh

Fluent Interfaces in TypeScript

Even if you don't recognize the term, you've more than likely used a Fluent Interface before. The simplest definition is, method chaining1. I think jQuery was my introduction to fluent interfaces. Remember writing code like this?

$("#toggleButton").click(() =>
    $(".box")
        .toggle()
        .css("background-color", "red")
        .css("color", "blue")
        .text((_, oldText) => `${oldText} - Toggled!`)
        .append("<p>Additional text appended!</p>")
);

jQuery was first released shortly after I started learning JavaScript. Of course, mainly, I loved how it made the DOM accessible to me. But as a very junior programmer, the concept of chaining methods together to create more complex behaviour was fascinating.

A more current example that we're probably all familiar with is assertion libraries:

expect(myObj.permissions).not.toContain("repo:delete");

Currently in my evolution as a software engineer, I try to spend a lot of time thinking about the consumption experience of my code: how can I make it easy for other engineers2 to use the functions, libraries, and APIs I write?

A fluent interface is one of the tools in that toolbox. And paired with good types, it becomes even stronger. So let's look at a few patterns for creating strongly-typed fluent interfaces in TypeScript.

A Basic QueryBuilder

Here's a basic example of a class with a fluent interface:

class QueryBuilder {
  private fields: string[] = [];
  private wheres: Record<string, string> = {};
  private table: string = '';

  select(...columns: string[]) {
    this.fields = columns;
    return this;
  }

  from(table: string) {
    this.table = table;
    return this;
  }

  where(column: string, value: string) {
    this.wheres[column] = value;
    return this;
  }

  build() {
    return `SELECT ${this.fields.join(", ")} FROM ${this.table} WHERE ${Object.entries(this.wheres)
      .map(([k, v]) => `${k} = ${v}`)
      .join(" AND ")};`;
  }
}

It's simple, but we can see the main tenets of fluent interfaces here. The select, from, and where methods all return this, which allows us to chain method calls together. Of course, since these methods return their object (and therefore can't return anything else), they must perform some side effect in order to be worth calling. Usually, that's managing some internal object state; here, we're setting the values of the fields and wheres properties.

We also need a way to terminate the chaining. And we've made the build method to be the last link in the chain. Instead of returning the object, it uses the object state to create the SQL query.

So, what does it look like to use this?

const query = new QueryBuilder()
  .select("name", "email")
  .from("users")
  .where("id", "1")
  .build();

(TS Playground)

This is a fairly fluent interface for building queries. It reads about as close to SQL as you could get in JavaScript, which I'd say makes it a decent DSL.

However, this leaves a lot to be desired for types; so let's look at another example.

Piping

This implementation is short, but it packs a lot in.

function pipe<A, B>(fn: (a: A) => B) {
  function run(a: A) {
    return fn(a);
  }

  run.pipe = <C>(fn2: (b: B) => C) =>
    pipe((a: A) =>
      fn2(fn(a))
    );

  return run;
}

This pipe function allows you to chain together other functions to create one "super-function", which passes an argument through every link in the chain.

The first thing you might notice is that we never return this. In fact, there isn't a class, or even an object, in sight! This time, we're using closure to store our state, in the form of one (fn) or two (fn2) function arguments.

pipe takes a single argument: a function that takes an A and returns a B. And it returns a function (run) that does the same thing!

However, the special sauce is the run.pipe assignment. We assign a function that takes a B-to-C function as its argument, and we call pipe on that, nesting a call to our first argument in there! So fn2(fn(arg)) is a call that goes from type A to type C, which gives us everything we need to type-safely chain a list of function calls.

Make sense? Sometimes, only an example will do:

const stringToDateAndTime = pipe(Date.parse)
  .pipe((n) => new Date(n))
  .pipe((d) => d.toISOString())
  .pipe((s) => s.split("T"))
  .pipe((a) => ({ date: a[0], time: a[1] }));

const result = stringToDateAndTime("Jan 1, 2024");
// result = { date: "2024-01-01", time: "05:00:00.000Z" }

We can start the chain by calling pipe and passing it a function. And then we continue the chain by repeatedly calling .pipe with other functions. Each of these functions will receive a strongly-typed argument, based on the return type of the previous link in the chain. And the result, stringToDateAndTime, is a function that takes a string, parses it to a date, splits the date into a date and time, and returns an object. You can play with this example in the TypeScript Playground.

I like this example because of its elegance: the generic types effortlessly allow the function types to flow through. It's also cool to see state captured via closure rather than objects.

But mainly, it's a good reminder that fluent interfaces often appear as though they are performing actions, but actually are enqueuing actions.

  • In our first example, methods like select and from do not actually query a database or even form a query; until we call build, nothing really happens (outside the object).
  • It's the same with pipe: we can pipe in new functions all day, but until we stop and actually call the resulting function with an argument, none of those piped functions are actually run.

I find this to be a good cue for situations where a fluent interface is useful: any time you want to build up a lot of configuration or perform a set of actions that result in a single output, you might find that a fluent interface is a good choice.

All Together Now

Finally, let's look at a stronger version of the QueryBuilder that we started with. This example is a simplified version of a toy project of mine, which aims to be a SQL query builder with incredibly robust types. You can check out the full version on GitHub, but this stripped-down example shows the main mechanics:

class QueryBuilder<Tables extends { [tableName: string]: BaseTable }> {
  table<N extends string, T extends BaseTable>() {
    return new QueryBuilder<Tables & { [X in N]: T }>(
      /* copy state over here */
    );
  }

  select(...cols: Columns<Tables>[]) {
    // implement here
    return this;
  }

  where<K extends Columns<Tables>>(col: K, value: Flat<Tables>[K]) {
    // implement here
    return this;
  }
}

// Helper Types
type BaseTable = {
  [colName: string]: string | number | boolean;
};

type Columns<Tables extends { [tableName: string]: BaseTable }> = {
  [K in keyof Tables]: K extends string ? (keyof Tables[K] extends string ? `${K}.${keyof Tables[K]}` : never) : never;
}[keyof Tables];

type Flat<Tables extends { [tableName: string]: BaseTable }> = {
  [K in Columns<Tables>]: Tables[K extends `${infer T}.${infer _}` ? T : never][K extends `${infer _}.${infer C}`
    ? C
    : never];
};

The main thing I want to highlight here is that table function. It's only purpose is to add new tables to the Tables type, so we can get good suggestions and typing when using select and where. It does this by returning a new QueryBuilder instance. It's difficult (actually, impossible, I think) to mutate the generic types of an object as you interact with it. So instead, we copy the state over to a new instance and give the new object the new types we want.

(The Column<T> and Flat<T> types are mainly sugar, so I won't explain them here, but do give them a test-drive, they're a good time.)

With this QueryBuilder, we can write code like this:

export const q = new QueryBuilder<{ user: { id: number; name: string } }>();

q.select("user.id", "user.name")
  .where("user.name", "andrew")
  .where("user.id", 3);

We initialize a QueryBuilder instance that only knows about our user table; from there we can select and filter on user columns. And we do have strong types on this! For example:

q.select("user.id", "user.age")
//                   ^^^^^^^^
// Argument of type '"user.age"' is not assignable
//  to parameter of type '"user.id" | "user.name"'.

Or

q.where("user.name", new Date())
//                   ^^^^^^^^^^
// Argument of type 'Date' is not assignable
//  to parameter of type 'string'.

But then, we can add an additional table, and query that as well:

q
  .select("user.id", "user.name")
  .table<"widget", { widgetId: string; userId: number }>()
  .select("widget.widgetId", "widget.userId")
  .where("widget.widgetId", 12);
//                          ^^
// Argument of type 'number' is not assignable
//  to parameter of type 'string'.

(TS Playground)

This example doesn't actually implement the internals (again, see a more complete example on GitHub) but it shows how TypeScript can enable non-trivial fluent interfaces that are a delight to use as a library consumer.

Inspiration

I've taken a lot of inspiration from Kysely for these QueryBuilder examples. If there are other examples of fluent interfaces that you find elegant or ergonomic, I'd love to hear about them!


  1. Actually, Martin Fowler, the coiner of the term, says a fluent interface is primarily about building a DSL, and not just method chaining. ↩︎

  2. Often, myself 6 months from now. ↩︎