Beyond Basics: Streamline Your Typescript Code With Fluent Interface Design Pattern

The Fluent Interface is a design pattern used to create object-oriented APIs that are easy to read and use. It allows developers to write code that reads like natural language. It achieves this by chaining multiple method calls together, resulting in a more readable, expressive, and intuitive syntax.

The principle behind the Fluent Interface is to allow developers to create a series of small, easy-to-read method calls that can be chained together. Each method call returns an instance of the object it was called on, allowing the developer to call the next method in the chain on the returned object.

Implementing Fluent Interface

Let's look at a simple example to illustrate how the Fluent Interface pattern works in Typescript:

class Person {
  private firstName: string;
  private lastName: string;
  private age: number;

  setFirstName(firstName: string): Person {
    this.firstName = firstName;
    return this;
  }

  setLastName(lastName: string): Person {
    this.lastName = lastName;
    return this;
  }

  setAge(age: number): Person {
    this.age = age;
    return this;
  }

  getInfo(): string {
    return `Name: ${this.firstName} ${this.lastName}, Age: ${this.age}`;
  }
}

const person = new Person()
  .setFirstName('John')
  .setLastName('Doe')
  .setAge(30);

console.log(person.getInfo()); // Name: John Doe, Age: 30

Example in CodeSandbox.

As you can see, each fluent method in the Person class returns this, which is a reference to itself. As it is also an instance of Person class, we can access all methods available on Person class again.

Beyond Basics

Now let's look at how we can implement the Fluent Interface in our own variation of one of the most popular testing libraries for JavaScript and TypeScript applications - Jest. Jest mimics the Fluent Interface pattern in its expect method to provide a more natural and intuitive syntax for writing assertions in tests.

The expect method in Jest takes an actual value and returns an object that allows developers to make various assertions on that value. Here's an example of how it can be used:

test('should return the correct sum of two numbers', () => {
  const result = sum(2, 3);
  expect(result).not.toBe(6);
});

Here we use the expect method to assert the result of a sum function. We chain the not and toBe method onto the object returned by expect to assert that the result of the sum method is not equal to the number 6.

Now let's look at how we could implement the expect method and underlying Expect class using the Fluent Interface pattern:

class Expect<T> {
  constructor(private actual: T) {}

  toBe(expected: T): this {
    if (this.actual !== expected) {
      throw new Error(`Expected ${expected}, but got ${this.actual}`);
    }
    return this;
  }

  not: NotExpect<T> = new NotExpect(this.actual);

  // other assertion methods...
}

class NotExpect<T> {
  constructor(private actual: T) {}

  toBe(expected: T): this {
    if (this.actual === expected) {
      throw new Error(`Expected ${expected} not to be ${this.actual}`);
    }
    return this;
  }

  // other assertion methods...
}

function expect<T>(actual: T): Expect<T> {
  return new Expect<T>(actual);
}

See the example in this CodeSandbox.

In this implementation, the not method is a member of the Expect class that returns a new instance of the NotExpect class. The NotExpect class has similar assertion methods to the Expect class, but all of its assertion methods negate the assertion.

The toBe method in the NotExpect class checks that the actual value is not equal to the expected value. If the values are equal, the method throws an error with a negated error message.

Benefits

Benefits of Fluent Interface Pattern:

  1. Readability: The Fluent Interface pattern improves the readability of code by providing a more natural and intuitive syntax.

  2. Chaining: The pattern allows developers to chain multiple method calls, resulting in more concise and easy-to-read code.

  3. Flexibility: The pattern provides flexibility in creating and configuring objects.

Drawbacks

The main two drawbacks of the Fluent Interface Pattern are overuse and with it connected issues with maintainability:

  1. Overuse: The Fluent Interface pattern can be overused, resulting in code that is difficult to read and maintain. Do not use this pattern in every place where it can be used, but reserve it for cases where it makes sense. Such cases can be, as in our example, fluent assertions or builders for complex objects that can have many ways of the optional configuration of their internal state.

  2. Maintainability: Adding new functionality to a fluent interface means extending the class that implements it. This might result in large classes with many methods if the interface grows. In that case, it is important to carefully split the class into multiple sub-groups and create an abstraction on top of them that connects them, so the interface can still be used fluently.

When used appropriately, the Fluent Interface pattern can result in code that is easier to read, write, and maintain. It is especially useful in testing libraries, where readability and ease of use are critical. Overall, the Fluent Interface pattern is a valuable tool in every developer's toolkit and is worth considering when designing applications.