Builder 패턴의 타입 — 메서드 체이닝을 타입으로 강제하기
Builder 패턴에 TypeScript 제네릭을 적용하면 필수 메서드 호출 여부 를 컴파일 타임에 검증하고, 메서드 체이닝 순서 를 타입으로 강제할 수 있습니다.
기본 Builder 패턴
// 일반적인 Builder — 타입 안전하지 않음
class QueryBuilder {
private table?: string;
private conditions: string[] = [];
private limit?: number;
from(table: string): this {
this.table = table;
return this;
}
where(condition: string): this {
this.conditions.push(condition);
return this;
}
take(limit: number): this {
this.limit = limit;
return this;
}
build(): string {
// ⚠️ table이 없으면 런타임 에러
if (!this.table) throw new Error('from()을 호출해야 합니다');
let query = `SELECT * FROM ${this.table}`;
if (this.conditions.length) query += ` WHERE ${this.conditions.join(' AND ')}`;
if (this.limit) query += ` LIMIT ${this.limit}`;
return query;
}
}
// ⚠️ from() 없이 build()를 호출해도 컴파일 에러 없음
new QueryBuilder().where('x > 1').build(); // 런타임 에러
타입 안전한 Builder
제네릭으로 "어떤 메서드가 호출되었는지"를 타입 레벨에서 추적합니다.
// 필수 필드 추적을 위한 플래그 타입
type BuilderState = {
hasFrom: boolean;
};
class TypedQueryBuilder<State extends BuilderState = { hasFrom: false }> {
private table?: string;
private conditions: string[] = [];
private limitValue?: number;
from(table: string): TypedQueryBuilder<{ hasFrom: true }> {
const builder = this as any;
builder.table = table;
return builder;
}
where(condition: string): TypedQueryBuilder<State> {
this.conditions.push(condition);
return this as any;
}
take(limit: number): TypedQueryBuilder<State> {
this.limitValue = limit;
return this as any;
}
// build()는 hasFrom이 true일 때만 호출 가능
build(this: TypedQueryBuilder<{ hasFrom: true }>): string {
let query = `SELECT * FROM ${this.table}`;
if (this.conditions.length) query += ` WHERE ${this.conditions.join(' AND ')}`;
if (this.limitValue) query += ` LIMIT ${this.limitValue}`;
return query;
}
}
// ✅ from()을 호출한 후 build() 가능
new TypedQueryBuilder()
.from('users')
.where('age > 18')
.build(); // OK
// ❌ from() 없이 build() 불가능
// new TypedQueryBuilder()
// .where('age > 18')
// .build(); // Error: 'this' context doesn't match
더 정교한 패턴: 단계별 Builder
// 각 단계를 별도 타입으로 분리
interface RequestConfig {
method: string;
url: string;
body?: unknown;
headers?: Record<string, string>;
}
// 단계 1: method를 선택해야 함
class RequestBuilder {
get(url: string): RequestBuilderWithMethod {
return new RequestBuilderWithMethod({ method: 'GET', url });
}
post(url: string): RequestBuilderWithMethodAndBody {
return new RequestBuilderWithMethodAndBody({ method: 'POST', url });
}
}
// 단계 2: method가 선택된 상태
class RequestBuilderWithMethod {
constructor(private config: RequestConfig) {}
header(key: string, value: string): this {
this.config.headers = { ...this.config.headers, [key]: value };
return this;
}
build(): RequestConfig {
return { ...this.config };
}
}
// 단계 3: POST인 경우 body가 필요
class RequestBuilderWithMethodAndBody {
constructor(private config: RequestConfig) {}
body(data: unknown): RequestBuilderWithMethod {
return new RequestBuilderWithMethod({ ...this.config, body: data });
}
}
// 사용
const getRequest = new RequestBuilder()
.get('/api/users')
.header('Authorization', 'Bearer token')
.build();
const postRequest = new RequestBuilder()
.post('/api/users')
.body({ name: '홍길동' }) // body를 넣어야 다음 단계로
.header('Content-Type', 'application/json')
.build();
// ❌ POST에서 body 없이 build 불가
// new RequestBuilder().post('/api/users').build(); // Error — build()가 없음
제네릭으로 설정 추적
// 어떤 속성이 설정되었는지 비트마스크처럼 추적
type FormBuilder<
HasName extends boolean = false,
HasEmail extends boolean = false,
> = {
setName(name: string): FormBuilder<true, HasEmail>;
setEmail(email: string): FormBuilder<HasName, true>;
// 둘 다 true일 때만 build 가능
build: HasName extends true
? HasEmail extends true
? () => { name: string; email: string }
: never
: never;
};
실전: Prisma 스타일 쿼리 빌더
type WhereClause<T> = Partial<Record<keyof T, unknown>>;
type OrderByClause<T> = Partial<Record<keyof T, 'asc' | 'desc'>>;
interface Query<T> {
where(clause: WhereClause<T>): Query<T>;
orderBy(clause: OrderByClause<T>): Query<T>;
take(limit: number): Query<T>;
skip(offset: number): Query<T>;
execute(): Promise<T[]>;
}
정리
- Builder 패턴에 제네릭을 적용하면 필수 메서드 호출을 컴파일 타임에 검증한다
- 상태 플래그 타입으로 "어떤 메서드가 호출되었는지"를 추적한다
- 단계별 Builder는 각 단계를 별도 클래스로 분리해서 순서를 강제한다
- 실무에서는 Prisma, Drizzle 같은 ORM이 이 패턴을 활용한다
- 과도한 타입 복잡성은 피하되, 핵심 API에서는 타입 안전성이 큰 도움이 된다
댓글 로딩 중...