Skip to Content
Course content

64: Practice Exercise: Building a Type-Safe Query Builder

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

When people first try to build a query builder in TypeScript, they usually fall into a specific trap: they think that simply typing the output of the builder is enough. They'll create a class, type the final execute() method to return a generic T[], and assume that the "type safety" part is handled.

Thinking the Return Type is Enough

I've seen this a dozen times. The developer writes a builder that looks like this:

class QueryBuilder<T> {
  select(columns: string[]) { 
    this.columns = columns; 
    return this; 
  }
  execute(): Promise<T[]> { 
    return db.run(this.columns); 
  }
}

At first glance, it looks okay. But look closer. If I have a User interface with id and email, I can still do this:

const users = await new QueryBuilder<User&gt>().select(['favorite_pizza_topping']).execute();

TypeScript is perfectly happy with that. Why? Because select() takes string[]. The "type safety" here is an illusion; it only exists at the very end of the chain. The actual construction of the query—where the most expensive bugs happen—is completely untyped. You've essentially just wrapped a runtime error in a fancy class.

Carrying the Schema Through the Chain

To actually make this type-safe, we need to stop treating the table schema as a label for the result and start treating it as a constraint for the methods. We do this by using keyof T to lock down the inputs of our builder methods.

Instead of string[], our select method should only accept keys that actually exist on T. Here is how I usually approach this when I'm building internal tools:

class QueryBuilder<T> {
  private columns: (keyof T)[] = [];

  select(...columns: (keyof T)[]): this {
    this.columns = columns;
    return this;
  }

  where(column: keyof T, value: any): this {
    // In a real app, we'd actually type 'value' based on T[column]
    return this;
  }

  async execute(): Promise<T[]> {
    console.log(`Fetching ${this.columns.join(', ')}...`);
    return []; 
  }
}

Now, if you try to select('favorite_pizza_topping') on a User object, the compiler will scream at you immediately. This is the core of the "Fluent Interface" pattern in TypeScript: the generic type T isn't just a placeholder for the end result; it's a guardrail that follows you through every single method call.

One little detail to watch out for: I used return this. In more complex builders, you might find yourself needing to change the type of this as the chain progresses (e.g., moving from a SelectBuilder to a WhereBuilder). For now, keeping the generic T consistent across the chain is the most important leap to make. It moves the validation from "hope it works at runtime" to "guaranteed by the compiler."




📋 Practical Task

Exercise: Implementing a Schema-Aware SQL Query Builder

Your goal is to complete a QueryBuilder class that prevents developers from querying columns that don't exist on a given model.

Requirements:

  • The select method must only accept arguments that are valid keys of the generic type T.
  • The where method must be fully type-safe: the first argument must be a key of T, and the second argument must match the type of that specific key (e.g., if T['id'] is a number, the value must be a number).
  • The execute method should return a Promise of an array of T.
interface User {
  id: number;
  username: string;
  isAdmin: boolean;
}

class QueryBuilder<T> {
  private selectedColumns: (keyof T)[] = [];
  private filters: { column: keyof T, value: any }[] = [];

  select(...columns: any[]): this {
    // TODO: Implement type-safe column selection
    return this;
  }

  where(column: any, value: any): this {
    // TODO: Implement type-safe filtering where 'value' matches T[column]
    return this;
  }

  async execute(): Promise<T[]> {
    return [];
  }
}

// --- TEST CASES ---
// This should compile:
const userQuery = new QueryBuilder<User&gt>()
  .select('username', 'isAdmin')
  .where('id', 123) 
  .execute();

// These should throw TypeScript errors:
// const badQuery1 = new QueryBuilder<User&gt>().select('password'); 
// const badQuery2 = new QueryBuilder<User&gt>().where('isAdmin', 'not-a-boolean');
Rating
0 0

There are no comments for now.

to be the first to leave a comment.