Photo by Christopher Gower on Unsplash
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:
Readability: The Fluent Interface pattern improves the readability of code by providing a more natural and intuitive syntax.
Chaining: The pattern allows developers to chain multiple method calls, resulting in more concise and easy-to-read code.
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:
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.
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.