When building client libraries, particularly for internal use, it can be tempting to do "just enough" to make it work, and then move on. This position would be at odds with the 2020 State of API report, however. It highlights that when it comes to evaluating and choosing tools, 70% of developers want "ease of use", and 68% care about "ease of implementation". Friction in the development experience, it seems, is bad for business.

This extra focus on Developer Experience is welcome, and at least when speaking about client libraries, not too difficult to achieve. You may, in fact, have come across one pattern that aims to provide a nicer interface for client configuration: the builder pattern.

Depending on what you're creating, It might look a little like this:

const client = AuthClientBuilder.From("example_jwt")
  .withAudience("example_audience")
  .withIssuer("example_issuer")
  .build();

client.validate();

This example auth client has a "fluent interface", which is a nice way of saying that you can read it like a spoken language, rather than like a robot. 🤖

First Steps

Let's create this example client, by starting with a simple definition. Here we're specifying the fields we expect, and a placeholder validate() method to simulate some auth behaviour.

import { assert } from "console";

export class AuthClient {
  private readonly _jwt: string;

  constructor(jwt: string) {
    assert(jwt, "JWT cannot be null or undefined.");
    this._jwt = jwt;
  }

  public validate() {
    // Client behaviour
  }
}

Okay, let's start defining the builder. This class defines a static method called From() (very fluent) which requires a JWT string to construct it. It also specifies a build() method to eventually create a client.

export class AuthClientBuilder {
  private readonly _jwt: string;

  constructor(jwt: string) {
    this._jwt = jwt;
  }

  public static From(jwt: string) {
    return new AuthClientBuilder(jwt);
  }

  public build() {
    return new AuthClient(this._jwt);
  }
}

If you were to use it at this point, it would be very easy:

const client = AuthClientBuilder.From('example_jwt')
	.build();

client.validate();

Wonderful, our DX is turned up to 11.

With Extras, Please

Let's introduce two additional settings to our client. We've extracted the error message for convenience and are continuing the same behaviour as before, by adding new parameters to the constructor:

import { assert } from "console";

export class AuthClient {
  private readonly _jwt: string;
  private readonly _audience: string; // 👈
  private readonly _issuer: string; // 👈

  private errorMsg = (value: string) => 
    `${value} cannot be null or undefined.`;

  constructor(
  	jwt: string, 
    audience: string, 
    issuer: string
    ) {
        assert(jwt, this.errorMsg("JWT"));
        assert(audience, this.errorMsg("Audience"));
        assert(issuer, this.errorMsg("Issuer"));
        this._jwt = jwt;
        this._audience = audience; // 👈
        this._issuer = issuer; // 👈
  }

  public validate() {
    // Client behaviour
  }
}

To match these requirements, we need to improve our builder by adding two with methods, which return the current instance of the builder, and allow you to chain its methods:

export class AuthClientBuilder {
  private readonly _jwt: string;
  private _audience: string;
  private _issuer: string;

  private errorMsg = (value: string) =>
    `${value} cannot be null, undefined, or empty.`;

  constructor(jwt: string) {
    this._jwt = jwt;
  }

  public static From(jwt: string) {
    return new AuthClientBuilder(jwt);
  }

  public withAudience(audience: string) { // 👈
    if (!audience || audience.length === 0) {
      throw new Error(this.errorMsg("Audience"));
    }
    this._audience = audience;
    return this;
  }
  
  public withIssuer(issuer: string) { // 👈
    if (!issuer || issuer.length === 0) {
      throw new Error(this.errorMsg("Issuer"));
    }
    this._issuer = issuer;
    return this;
  }

  public build() {
    return new AuthClient(
        this._jwt, 
        this._audience, 
        this._issuer
    );
  }
}

Great! We've now matched our original client usage example and specified two new parameters in the classic builder style. A developer would use it like this:

const client = AuthClientBuilder.From("example_jwt")
  .withAudience("example_audience")
  .withIssuer("example_issuer")
  .build();

client.validate();

This pattern is nice when you want to provide a flexible set of options for users to choose from. But what if you want to control the steps, make some steps mandatory, or make others optional? For that, we can extend the builder pattern, and make a step builder.

A Step Up

First, let's update our client to include an optional permission:

import { assert } from "console";

export class AuthClient {
  private readonly _jwt: string;
  private readonly _audience: string;
  private readonly _issuer: string;
  private readonly _permission?: string; // 👈

  private errorMsg = (value: string) => 
  	`${value} cannot be null or undefined.`;

  constructor(
    jwt: string,
    audience: string,
    issuer: string,
    permission: string // 👈
  ) {
    assert(jwt, this.errorMsg("JWT"));
    assert(audience, this.errorMsg("Audience"));
    assert(issuer, this.errorMsg("Issuer"));
    this._jwt = jwt;
    this._audience = audience;
    this._issuer = issuer;
    this._permission = permission; // 👈
  }

  public validate() {
    // Client behaviour
  }
}

Then, let's make a couple of small changes to our Builder:

export class AuthClientBuilder {
  private readonly _jwt: string;
  private _audience: string;
  private _issuer: string;
  private _permission: string;

  constructor(jwt: string) {
    this._jwt = jwt;
  }

  public static From(jwt: string) {
    const builder = new AuthClientBuilder(jwt);
    return new AudienceStepBuilder(builder); // Magic ✨
  }

  public build() {
    return new AuthClient(
      this._jwt,
      this._audience,
      this._issuer,
      this._permission
    );
  }
  
  set audience(audience: string) { // 👈
    this._audience = audience;
  }
  
  set issuer(issuer: string) { // 👈
    this._issuer = issuer;
  }
  
  set permission(permission: string) { // 👈
    this._permission = permission;
  }
}

We've removed our fluent "with" statements from the builder, added setters for the parameters, and are now doing something special with our constructor. Specifically, we're returning another builder! This is builder chaining and is very useful.

Chaining, in this context, means that when you call a builder's with method, you get another builder back. Then you can call new with methods exposed by that new builder. It looks a little like this:

const client = Builder1.actionOne() // This returns Builder 2
	.actionTwo() // This returns Builder 3
	.actionThree() // This returns Builder 4
	.build() // This creates the client

This allows us to compose our desired behaviour from various steps, encapsulated in their own little builders. It also allows us to control what step comes next in the chain, if order is important.

Let's look at one of these little builders:

export class AudienceStepBuilder {
  private readonly _builder: AuthClientBuilder;

  constructor(builder: AuthClientBuilder) {
    this._builder = builder;
  }

  public withAudience(audience: string) {
    if (!audience || audience.length === 0) {
      throw new Error("Audience cannot be null, undefined, or empty.");
    }
    this._builder.audience = audience;
    return new IssuerStepBuilder(this._builder);
  }
}

Isn't that tiny? It accepts the previous builder (so that you can chain them) and now encloses the fluent "with" statement from our original example. Critically, it returns the next step in the builder chain.

Let's look at the next one:

export class IssuerStepBuilder {
  private readonly _builder: AuthClientBuilder;

  constructor(builder: AuthClientBuilder) {
    this._builder = builder;
  }

  public withIssuer(issuer: string) {
    if (!issuer || issuer.length === 0) {
      throw new Error("Issuer cannot be null, undefined, or empty.");
    }
    this._builder.issuer = issuer;
    return new OptionalPermissionStepBuilder(this._builder);
  }
}

It's equally tiny! It only includes a single required operation, and then allows you to move on.

Optional Extras

So far we've ensured that each step in our new builder is required, as you need to move through each link in the chain to complete the client. What about optional steps, though? Well, that requires a final step builder, but this one is a bit different:

export class OptionalPermissionStepBuilder {
  private readonly _builder: AuthClientBuilder;

  constructor(builder: AuthClientBuilder) {
    this._builder = builder;
  }

  public withPermission(permission: string) {
    if (!permission || permission.length === 0) {
      throw new Error("Permission cannot be null, undefined, or empty.");
    }
    this._builder.permission = permission;
    return this; // 👈 No more steps!
  }

  public build() {
    return this._builder.build();
  }
}

When called, this builder exposes two methods: withPermission() and a vital build() which invokes the main builder's method. Because this step exposes more than one method, you now have a choice. You can choose to use either method, or both, and still construct your client.

This will be a valid expression:

const client = AuthClientBuilder.From("example_jwt")
  .withAudience("example_audience")
  .withIssuer("example_issuer")
  .build();

client.validate();

And so will this:

const client = AuthClientBuilder.From("example_jwt")
  .withAudience("example_audience")
  .withIssuer("example_issuer")
  .withPermission('optional_permission') // Shiny ✨
  .build();

client.validate();

This optional step provides a great deal of flexibility to both the developer writing the client library, and the developer using the library. It also leaves little room for error, as the chain only shows the next allowed step, narrowing the opportunity for mistakes.

Importantly, too, for all the required methods we've chained, you'll get immediate compile-time checking. This ensures you're providing the minimal required configuration to execute the client in production, with an option for further customisation.

Takeaways

Despite divided opinions on the relevance of design patterns, the builder pattern remains a very useful tool for customising an object's construction, particularly when part of a client library.

If you find that you need to control an object's construction, or make some parameters mandatory/optional, consider extending the builder pattern with steps. It balances the increasing developer expectations for ease of use, with a small additional effort from the maintainer.

Finally, the implementation incurs no additional overhead when testing. You may write tests first/during/after, and as long as you're targeting the interface rather than the implementation, you're golden.

To see what we've built in one single gist, click to expand.
  
import { assert } from "console";

export class AuthClient {
private readonly _jwt: string;
private readonly _audience: string;
private readonly _issuer: string;
private readonly _permission?: string;

private errorMsg = (value: string) => 
  `${value} cannot be null or undefined.`;

constructor(
  jwt: string,
  audience: string,
  issuer: string,
  permission: string
) {
  assert(jwt, this.errorMsg("JWT"));
  assert(audience, this.errorMsg("Audience"));
  assert(issuer, this.errorMsg("Issuer"));
  this._jwt = jwt;
  this._audience = audience;
  this._issuer = issuer;
  this._permission = permission;
}

public validate() {
  // Client behaviour
}
}

export class AuthClientBuilder {
private readonly _jwt: string;
private _audience: string;
private _issuer: string;
private _permission: string;

constructor(jwt: string) {
  this._jwt = jwt;
}

public static From(jwt: string) {
  const builder = new AuthClientBuilder(jwt);
  return new AudienceStepBuilder(builder); // Magic ✨
}

public build() {
  return new AuthClient(
    this._jwt,
    this._audience,
    this._issuer,
    this._permission
  );
}

set audience(audience: string) {
  this._audience = audience;
}

set issuer(issuer: string) {
  this._issuer = issuer;
}

set permission(permission: string) {
  this._permission = permission;
}
}

export class AudienceStepBuilder {
private readonly _builder: AuthClientBuilder;

constructor(builder: AuthClientBuilder) {
  this._builder = builder;
}

public withAudience(audience: string) {
  if (!audience || audience.length === 0) {
    throw new Error("Audience cannot be null, undefined, or empty.");
  }
  this._builder.audience = audience;
  return new IssuerStepBuilder(this._builder);
}
}

export class IssuerStepBuilder {
private readonly _builder: AuthClientBuilder;

constructor(builder: AuthClientBuilder) {
  this._builder = builder;
}

public withIssuer(issuer: string) {
  if (!issuer || issuer.length === 0) {
    throw new Error("Issuer cannot be null, undefined, or empty.");
  }
  this._builder.issuer = issuer;
  return new OptionalPermissionStepBuilder(this._builder);
}
}

export class OptionalPermissionStepBuilder {
private readonly _builder: AuthClientBuilder;

constructor(builder: AuthClientBuilder) {
  this._builder = builder;
}

public withPermission(permission: string) {
if (!permission || permission.length === 0) {
  throw new Error("Permission cannot be null, undefined, or empty.");
}
this._builder.permission = permission;
return this;
}

public build() {
  return this._builder.build();
}
}

const client = AuthClientBuilder.From("example_jwt")
.withAudience("example_audience")
.withIssuer("example_issuer")
.withPermission("optional_permission") // Shiny ✨
.build();

client.validate();