Builder 패턴에 TypeScript 제네릭을 적용하면 필수 메서드 호출 여부 를 컴파일 타임에 검증하고, 메서드 체이닝 순서 를 타입으로 강제할 수 있습니다.

기본 Builder 패턴

TYPESCRIPT
// 일반적인 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

제네릭으로 "어떤 메서드가 호출되었는지"를 타입 레벨에서 추적합니다.

TYPESCRIPT
// 필수 필드 추적을 위한 플래그 타입
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

TYPESCRIPT
// 각 단계를 별도 타입으로 분리
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()가 없음

제네릭으로 설정 추적

TYPESCRIPT
// 어떤 속성이 설정되었는지 비트마스크처럼 추적
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 스타일 쿼리 빌더

TYPESCRIPT
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에서는 타입 안전성이 큰 도움이 된다
댓글 로딩 중...