Right, let’s talk about fluent builders. You’ve seen them everywhere—jQuery’s $('#element').css('color', 'red').fadeOut(), your favorite query builder, that config object you can’t seem to stop chaining methods onto. They’re glorious. They turn a series of imperative, clunky statements into a single, readable, almost declarative sentence.

But building one yourself in TypeScript? It’s a fantastic exercise in bending the type system to your will to create something that’s not just clever but genuinely pleasant and safe to use. The goal isn’t just method chaining; it’s intelligent method chaining where the types guide you and prevent you from making a complete mess.

The Core Idea: return this; (But Better)

The absolute bedrock of a fluent API is that each method returns the object itself, so you can immediately call another method on the return value. In a class, that means return this;.

class BasicFluentBuilder {
  private value: number = 0;

  add(n: number): this {
    this.value += n;
    return this;
  }

  multiply(n: number): this {
    this.value *= n;
    return this;
  }

  getValue(): number {
    return this.value;
  }
}

// Usage: It just works.
const result = new BasicFluentBuilder()
  .add(5)    // Type is BasicFluentBuilder
  .multiply(2) // Still BasicFluentBuilder
  .add(1)    // Yep, still BasicFluentBuilder
  .getValue(); // 11

The : this return type is crucial here. It’s a polymorphic this type. If you subclass BasicFluentBuilder, methods in the subclass will automatically return the subclass’s type, not the parent’s. This is your first line of defense against breaking the fluent pattern in an inheritance hierarchy.

Leveling Up: Accumulating State in the Type System

The simple builder above is fine, but it’s dumb. It doesn’t know what you’ve configured yet. A truly powerful fluent builder tracks its state at the type level. The methods available at any given point should depend on what you’ve already done. You can’t call .execute() before you’ve called .forUrl(), right?

We achieve this by changing the return type of our methods. Instead of always returning this, we return a new instance of the class (or a different interface) that reflects the newly accumulated state. This is where TypeScript’s type algebra becomes your best friend.

Let’s build a (very) simplified database query builder. The steps are: 1) Select a table, 2) Add optional filters, 3) Execute.

// First, we define the possible states of our builder.
// These are just type markers; they don't hold any runtime data.
interface InitialState {
  hasFrom: false;
}
interface TableSelectedState {
  hasFrom: true;
  table: string;
}
interface ReadyToExecuteState extends TableSelectedState {
  hasWhere: true;
}

// Our builder class is a generic that takes a State type.
// The default state is InitialState.
class QueryBuilder<State extends InitialState | TableSelectedState | ReadyToExecuteState = InitialState> {
  // Private state accumulated at runtime
  private state: {
    table?: string;
    whereClauses: string[];
  } = { whereClauses: [] };

  // The constructor is private. We'll use a static method to start.
  private constructor() {}

  // Start the fluent chain
  static from(): QueryBuilder<InitialState> {
    return new QueryBuilder();
  }

  // This method is only available if the State is InitialState
  from<T extends string>(table: T): QueryBuilder<TableSelectedState> {
    // At runtime, we mutate the internal state.
    this.state.table = table;
    // But the magic is here: we return 'this' cast to a new TYPE.
    // This tells TypeScript the builder has now entered a new state.
    return this as unknown as QueryBuilder<TableSelectedState>;
  }

  // This method is only available if the State has a table (i.e., TableSelectedState or ReadyToExecuteState)
  where(condition: string): QueryBuilder<ReadyToExecuteState> {
    if (!this.state.table) {
      throw new Error('You must call .from() before .where()');
    }
    this.state.whereClauses.push(condition);
    return this as unknown as QueryBuilder<ReadyToExecuteState>;
  }

  // This method is only available if the State is ReadyToExecuteState
  execute(): { data: string[] } {
    if (!this.state.table) {
      throw new Error('You must call .from() before .execute()');
    }
    // Build the query from the internal state...
    const whereClause = this.state.whereClauses.length > 0 ? ` WHERE ${this.state.whereClauses.join(' AND ')}` : '';
    const query = `SELECT * FROM ${this.state.table}${whereClause};`;
    console.log(`Executing: ${query}`); // Imagine this goes to a DB
    return { data: ['result1', 'result2'] };
  }
}

Now, watch the type system guide you and prevent errors:

// This works perfectly:
const results = QueryBuilder.from()
  .from('users') // Now type is QueryBuilder<TableSelectedState>
  .where('age > 18') // Now type is QueryBuilder<ReadyToExecuteState>
  .execute(); // Allowed!

// This is a compile-time ERROR:
QueryBuilder.from()
  .execute(); // Property 'execute' does not exist on type 'QueryBuilder<InitialState>'.

// This is also a compile-time ERROR:
QueryBuilder.from()
  .from('users')
  .where('age > 18')
  .where('name = "Alice"') // ERROR: Property 'where' does not exist on type 'QueryBuilder<ReadyToExecuteState>'.
// Wait, what? That seems wrong.

Ah, the last one is a classic pitfall. Our .where() method always returns ReadyToExecuteState, which we defined as having hasWhere: true. But we didn’t define a state that allows multiple .where() calls! The type system, correctly following our sloppy instructions, thinks you’re done after the first one. This is why designing your state transitions is the most important part of the process.

Best Practices and Pitfalls

  1. Design Your State Graph First: Draw it out. What are the possible states? Which methods transition between them? Our error above happened because we didn’t have a state for “has a table and one or more where clauses, but might accept more.”
  2. Runtime Checks are Still Your Friend: The type system is a development-time safety net. You should still throw clear errors for invalid runtime conditions (e.g., if (!this.state.table) throw ...). This catches issues the type system can’t, like logic errors within a given state.
  3. Avoid any and Casts When Possible: My example used as unknown as ... to change the type. It’s a necessary evil sometimes, but keep it contained to these well-defined method boundaries. Never let any leak out to the consumer.
  4. It’s Okay to End the Chain: A fluent builder doesn’t have to be infinite. Methods like .execute(), .build(), or .get() that return a final value are what make the pattern useful. They break the chain and give you the fruit of your labor.
  5. Complexity Has a Cost: This pattern can lead to complex type code. Ask yourself if it’s worth it. For a major public API? Absolutely. For a small internal config object? Maybe just use a regular builder with return this; and good documentation.

The beauty of this pattern is that it makes incorrect code look and feel wrong as you type it. It turns runtime errors into compile-time errors, which is pretty much the point of using TypeScript. You’re not just building an API; you’re building a guardrail.