-
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
210: Practice Exercise: Building a Fluent Builder Pattern with Types
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
UserAccounttype withemail(string),password(string),username(optional string), andphoneNumber(optional string). - Implement a
UserAccountBuilderusing the Generic state-tracking pattern explored in the lesson. - The
.build()method must returnnever(or be inaccessible) unless bothsetEmail()andsetPassword()have been called. setUsername()andsetPhoneNumber()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();
There are no comments for now.