-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
64: Practice Exercise: Building a Type-Safe Query Builder
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>>().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
selectmethod must only accept arguments that are valid keys of the generic typeT. - The
wheremethod must be fully type-safe: the first argument must be a key ofT, and the second argument must match the type of that specific key (e.g., ifT['id']is a number, the value must be a number). - The
executemethod should return a Promise of an array ofT.
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>>()
.select('username', 'isAdmin')
.where('id', 123)
.execute();
// These should throw TypeScript errors:
// const badQuery1 = new QueryBuilder<User>>().select('password');
// const badQuery2 = new QueryBuilder<User>>().where('isAdmin', 'not-a-boolean');
There are no comments for now.