Skip to Content
Course content

210: Practice Exercise: Building a Fluent Builder Pattern with Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've always liked the Builder pattern for constructing complex objects—it keeps the constructor from becoming a "telescoping" mess of ten optional arguments. But there's a problem that's always bugged me: how do we make the builder aware of what's still missing? If I'm building an HTTP request, I can't just send it without a URL, right? In plain JavaScript or basic TypeScript, the compiler doesn't care if I call .build() before setting the essentials.

The "too simple" approach

Let's start where most people do. I'll create a simple class where each method returns this to allow chaining. I'll try to build a HttpRequest object.

type HttpRequest = {
  url: string;
  method: 'GET' | 'POST';
  body?: string;
};

class RequestBuilder {
  private request: Partial<HttpRequest> = {};

  setUrl(url: string) {
    this.request.url = url;
    return this;
  }

  setMethod(method: 'GET' | 'POST') {
    this.request.method = method;
    return this;
  }

  setBody(body: string) {
    this.request.body = body;
    return this;
  }

  build(): HttpRequest {
    return this.request as HttpRequest;
  }
}

// This looks great... until I do this:
const req = new RequestBuilder().build(); 
// TypeScript is happy, but the object is empty. Runtime crash imminent.

The problem here is that build() is available the moment the class is instantiated. I'm using a type cast (as HttpRequest) to trick the compiler, which is exactly what we want to avoid. I want the compiler to scream at me if I haven't called setUrl and setMethod.

Trying to track state with interfaces

I wonder if I can use different interfaces to represent the "state" of the builder. If I'm in the "Initial" state, I should only be able to call the setters. Once I've set the URL and Method, I should transition to a "Ready" state where build() becomes visible.

Let's try to model this. I'll define what the builder looks like when it's missing things versus when it's complete.

interface IRequestBuilder {
  setUrl(url: string): IRequestBuilder;
  setMethod(method: 'GET' | 'POST'): IRequestBuilder;
  setBody(body: string): IRequestBuilder;
}

interface IReadyBuilder extends IRequestBuilder {
  build(): HttpRequest;
}

class RequestBuilder implements IRequestBuilder {
  private request: Partial<HttpRequest> = {};

  setUrl(url: string) {
    this.request.url = url;
    return this as any; // I'm hacking this for a second to see if it works
  }

  setMethod(method: 'GET' | 'POST') {
    this.request.method = method;
    return this as any;
  }

  setBody(body: string) {
    this.request.body = body;
    return this as any;
  }

  build() {
    return this.request as HttpRequest;
  }
}

This feels clunky. I'm using as any because the class can't magically change its own type based on which method was called. TypeScript types are static; they don't typically "evolve" at runtime. To get this to actually work, I need to move the state into the type system using Generics.

Solving it with Generic State Tracking

Here is the "aha!" moment. Instead of trying to change the class, I'll make the builder generic. The generic type will represent the "missing" fields. As I call methods, I'll return a new version of the builder where that field is removed from the "missing" list.

I'll use a trick here: if the "missing" list is empty, I'll allow the build method to exist.

type HttpRequest = {
  url: string;
  method: 'GET' | 'POST';
  body?: string;
};

// We track what is MISSING.
class RequestBuilder<TMissing extends Partial<HttpRequest> = HttpRequest> {
  private request: Partial<HttpRequest> = {};

  setUrl(url: string): RequestBuilder<Omit<TMissing, 'url'>> {
    this.request.url = url;
    return this as any;
  }

  setMethod(method: 'GET' | 'POST'): RequestBuilder<Omit<TMissing, 'method'>> {
    this.request.method = method;
    return this as any;
  }

  setBody(body: string): RequestBuilder<TMissing> {
    this.request.body = body;
    return this as any;
  }

  // The magic: build() only exists if TMissing is an empty object
  build(): TMissing extends Record<string, never> ? HttpRequest : never {
    return this.request as any;
  }
}

// Let's test it.
const builder = new RequestBuilder();

// builder.build(); 
// ^ ERROR: Property 'build' does not exist on type 'RequestBuilder<HttpRequest>' 
// (Wait, that's not quite right, the return type is 'never', so it's unusable).

const validReq = builder
  .setUrl('https://api.example.com')
  .setMethod('GET')
  .build(); // Works!

Wait, I noticed something. If I call .build() too early, it doesn't actually "disappear" from the autocomplete, but the return type becomes never. If I want to strictly forbid the method from even being called, I'd have to use a more complex interface-switching pattern, but for most real-world engineering, this Generic approach is the sweet spot. It tells the developer: "You can't actually get an HttpRequest out of this until you've satisfied the requirements."

By using Omit<TMissing, 'key'>, we are essentially checking off a list. Once the list is empty (Record<string, never>), the build method finally yields the actual object. It's like a type-level checklist.




📋 Practical Task

Exercise: Implement a SecureUserAccount Builder

You are building a system to create UserAccount objects. For security reasons, an account cannot be created unless it has both an email and a password. The username and phoneNumber are optional.

Requirements:

  • Create a UserAccount type with email (string), password (string), username (optional string), and phoneNumber (optional string).
  • Implement a UserAccountBuilder using the Generic state-tracking pattern explored in the lesson.
  • The .build() method must return never (or be inaccessible) unless both setEmail() and setPassword() have been called.
  • setUsername() and setPhoneNumber() should be available at any time and should not affect the "readiness" of the builder.

Test your implementation with these cases:

// This should fail to compile or return 'never'
const badUser = new UserAccountBuilder().setEmail('test@test.com').build();

// This should compile and return a UserAccount
const goodUser = new UserAccountBuilder()
  .setEmail('dev@example.com')
  .setPassword('secret123')
  .setUsername('ts_wizard')
  .build();
Rating
0 0

There are no comments for now.

to be the first to leave a comment.