Builder pattern com TypeScript

1. Fundamentos do Builder Pattern

O padrão Builder é um padrão de projeto criacional que permite construir objetos complexos passo a passo. Diferente do Factory, que geralmente cria objetos em uma única chamada, o Builder separa o processo de construção da representação final, permitindo maior controle sobre cada etapa.

Diferenças principais:
- Factory: Cria objetos completos em uma única operação
- Constructor: Pode se tornar confuso com muitos parâmetros opcionais
- Builder: Permite construção incremental, com métodos encadeados e validação em cada etapa

Quando usar Builder:
- Objetos com muitos parâmetros opcionais (mais de 3-4)
- Necessidade de imutabilidade durante a construção
- Processos de construção que exigem validação em etapas específicas
- Diferentes representações do mesmo processo de construção

2. Builder Simples com Tipos Genéricos

Vamos implementar um Builder básico para construção de consultas HTTP:

class HttpRequestBuilder {
  private url: string = '';
  private method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET';
  private headers: Record<string, string> = {};
  private body?: unknown;

  setUrl(url: string): this {
    this.url = url;
    return this;
  }

  setMethod(method: 'GET' | 'POST' | 'PUT' | 'DELETE'): this {
    this.method = method;
    return this;
  }

  addHeader(key: string, value: string): this {
    this.headers[key] = value;
    return this;
  }

  setBody(body: unknown): this {
    this.body = body;
    return this;
  }

  build(): HttpRequest {
    if (!this.url) throw new Error('URL is required');
    return {
      url: this.url,
      method: this.method,
      headers: { ...this.headers },
      body: this.body
    };
  }
}

interface HttpRequest {
  url: string;
  method: string;
  headers: Record<string, string>;
  body?: unknown;
}

// Uso
const request = new HttpRequestBuilder()
  .setUrl('https://api.example.com/users')
  .setMethod('POST')
  .addHeader('Content-Type', 'application/json')
  .setBody({ name: 'John' })
  .build();

3. Builder com Etapas Obrigatórias (Staged Builder)

Para garantir que métodos sejam chamados em ordem específica, podemos usar tipos encadeados:

class EmailBuilder {
  private from: string = '';
  private to: string = '';
  private subject: string = '';
  private body: string = '';

  setFrom(from: string): EmailBuilderWithTo {
    return new EmailBuilderWithTo({ ...this, from });
  }
}

class EmailBuilderWithTo {
  constructor(private state: Partial<EmailBuilder>) {}

  setTo(to: string): EmailBuilderWithSubject {
    return new EmailBuilderWithSubject({ ...this.state, to });
  }
}

class EmailBuilderWithSubject {
  constructor(private state: Partial<EmailBuilder>) {}

  setSubject(subject: string): EmailBuilderWithBody {
    return new EmailBuilderWithBody({ ...this.state, subject });
  }
}

class EmailBuilderWithBody {
  constructor(private state: Partial<EmailBuilder>) {}

  setBody(body: string): EmailBuilderReady {
    return new EmailBuilderReady({ ...this.state, body });
  }
}

class EmailBuilderReady {
  constructor(private state: Partial<EmailBuilder>) {}

  build(): Email {
    if (!this.state.from || !this.state.to || !this.state.subject || !this.state.body) {
      throw new Error('Missing required fields');
    }
    return this.state as Email;
  }
}

interface Email {
  from: string;
  to: string;
  subject: string;
  body: string;
}

// Uso
const email = new EmailBuilder()
  .setFrom('sender@example.com')
  .setTo('recipient@example.com')
  .setSubject('Hello')
  .setBody('World')
  .build();

4. Builder Fluent com Discriminated Unions Internas

Combinando Builder com discriminated unions para estados internos:

type OrderState = 
  | { status: 'draft'; items: string[] }
  | { status: 'confirmed'; items: string[]; total: number }
  | { status: 'shipped'; items: string[]; total: number; tracking: string };

class OrderBuilder {
  private state: OrderState = { status: 'draft', items: [] };

  addItem(item: string): this {
    if (this.state.status === 'draft') {
      this.state.items.push(item);
    }
    return this;
  }

  confirm(): OrderBuilderConfirmed {
    if (this.state.status !== 'draft') {
      throw new Error('Order must be in draft status');
    }
    const total = this.state.items.length * 10; // Preço fictício
    return new OrderBuilderConfirmed({
      status: 'confirmed',
      items: [...this.state.items],
      total
    });
  }
}

class OrderBuilderConfirmed {
  constructor(private state: Extract<OrderState, { status: 'confirmed' }>) {}

  ship(tracking: string): OrderBuilderShipped {
    return new OrderBuilderShipped({
      ...this.state,
      status: 'shipped',
      tracking
    });
  }

  build(): Extract<OrderState, { status: 'confirmed' }> {
    return { ...this.state };
  }
}

class OrderBuilderShipped {
  constructor(private state: Extract<OrderState, { status: 'shipped' }>) {}

  build(): Extract<OrderState, { status: 'shipped' }> {
    return { ...this.state };
  }
}

5. Builder Assíncrono e com Validação

Implementando build assíncrono com validação tipada:

class DatabaseConnectionBuilder {
  private host: string = 'localhost';
  private port: number = 5432;
  private username: string = '';
  private password: string = '';
  private database: string = '';

  setHost(host: string): this {
    this.host = host;
    return this;
  }

  setPort(port: number): this {
    this.port = port;
    return this;
  }

  setCredentials(username: string, password: string): this {
    this.username = username;
    this.password = password;
    return this;
  }

  setDatabase(database: string): this {
    this.database = database;
    return this;
  }

  async build(): Promise<DatabaseConnection> {
    const errors: string[] = [];

    if (!this.username) errors.push('Username is required');
    if (!this.password) errors.push('Password is required');
    if (!this.database) errors.push('Database name is required');

    if (errors.length > 0) {
      throw new ValidationError(errors);
    }

    // Simula validação assíncrona
    const isValid = await this.testConnection();
    if (!isValid) {
      throw new ConnectionError('Failed to connect to database');
    }

    return {
      host: this.host,
      port: this.port,
      username: this.username,
      database: this.database,
      connected: true
    };
  }

  private async testConnection(): Promise<boolean> {
    // Simula teste de conexão
    return new Promise(resolve => setTimeout(() => resolve(true), 100));
  }
}

class ValidationError extends Error {
  constructor(public errors: string[]) {
    super(errors.join(', '));
    this.name = 'ValidationError';
  }
}

class ConnectionError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ConnectionError';
  }
}

interface DatabaseConnection {
  host: string;
  port: number;
  username: string;
  database: string;
  connected: boolean;
}

6. Builder com Herança e Interfaces Complexas

Construindo builders para hierarquias de classes:

interface NotificationConfig {
  recipient: string;
  content: string;
  priority: 'low' | 'medium' | 'high';
}

abstract class NotificationBuilder<T extends NotificationConfig> {
  protected config: Partial<T> = {};

  setRecipient(recipient: string): this {
    this.config.recipient = recipient as T['recipient'];
    return this;
  }

  setContent(content: string): this {
    this.config.content = content as T['content'];
    return this;
  }

  setPriority(priority: 'low' | 'medium' | 'high'): this {
    this.config.priority = priority as T['priority'];
    return this;
  }

  abstract build(): T;
}

interface EmailNotificationConfig extends NotificationConfig {
  subject: string;
  attachments?: string[];
}

class EmailNotificationBuilder extends NotificationBuilder<EmailNotificationConfig> {
  setSubject(subject: string): this {
    this.config.subject = subject;
    return this;
  }

  addAttachment(file: string): this {
    if (!this.config.attachments) {
      this.config.attachments = [];
    }
    this.config.attachments.push(file);
    return this;
  }

  build(): EmailNotificationConfig {
    if (!this.config.subject) {
      throw new Error('Subject is required for email notifications');
    }
    return {
      recipient: this.config.recipient!,
      content: this.config.content!,
      priority: this.config.priority || 'medium',
      subject: this.config.subject,
      attachments: this.config.attachments
    };
  }
}

7. Builder Imutável com Cópia Parcial

Implementando um builder que retorna novas instâncias:

class ImmutableSQLQueryBuilder {
  private readonly select: string[] = [];
  private readonly from: string = '';
  private readonly where: string[] = [];
  private readonly orderBy: string[] = [];

  private constructor(state?: Partial<ImmutableSQLQueryBuilder>) {
    if (state) {
      this.select = [...(state.select || [])];
      this.from = state.from || '';
      this.where = [...(state.where || [])];
      this.orderBy = [...(state.orderBy || [])];
    }
  }

  static create(): ImmutableSQLQueryBuilder {
    return new ImmutableSQLQueryBuilder();
  }

  addSelect(field: string): ImmutableSQLQueryBuilder {
    return new ImmutableSQLQueryBuilder({
      ...this,
      select: [...this.select, field]
    });
  }

  setFrom(table: string): ImmutableSQLQueryBuilder {
    return new ImmutableSQLQueryBuilder({
      ...this,
      from: table
    });
  }

  addWhere(condition: string): ImmutableSQLQueryBuilder {
    return new ImmutableSQLQueryBuilder({
      ...this,
      where: [...this.where, condition]
    });
  }

  addOrderBy(field: string): ImmutableSQLQueryBuilder {
    return new ImmutableSQLQueryBuilder({
      ...this,
      orderBy: [...this.orderBy, field]
    });
  }

  build(): string {
    if (!this.from) throw new Error('FROM clause is required');

    const parts: string[] = [];
    parts.push(`SELECT ${this.select.join(', ') || '*'}`);
    parts.push(`FROM ${this.from}`);

    if (this.where.length > 0) {
      parts.push(`WHERE ${this.where.join(' AND ')}`);
    }

    if (this.orderBy.length > 0) {
      parts.push(`ORDER BY ${this.orderBy.join(', ')}`);
    }

    return parts.join(' ');
  }
}

8. Boas Práticas e Armadilhas Comuns

Evitar builders "inchados": Quando um builder tem muitos métodos opcionais, considere dividi-lo em builders menores ou usar composição.

Testabilidade: Teste cada método do builder isoladamente e o resultado final:

describe('ImmutableSQLQueryBuilder', () => {
  it('should build a basic SELECT query', () => {
    const query = ImmutableSQLQueryBuilder.create()
      .addSelect('name')
      .setFrom('users')
      .build();

    expect(query).toBe('SELECT name FROM users');
  });

  it('should be immutable', () => {
    const builder = ImmutableSQLQueryBuilder.create()
      .setFrom('users');

    const query1 = builder.addWhere('age > 18').build();
    const query2 = builder.build();

    expect(query1).toContain('WHERE');
    expect(query2).not.toContain('WHERE');
  });
});

Performance: Para builders que são chamados frequentemente, considere lazy evaluation:

class LazyQueryBuilder {
  private operations: Array<() => void> = [];

  addSelect(field: string): this {
    this.operations.push(() => { /* adiciona select */ });
    return this;
  }

  build(): string {
    // Aplica todas as operações apenas no build
    this.operations.forEach(op => op());
    return this.generateQuery();
  }

  private generateQuery(): string {
    return 'SELECT * FROM users'; // Exemplo simplificado
  }
}

O Builder Pattern em TypeScript oferece uma maneira elegante e segura de construir objetos complexos. Quando combinado com o sistema de tipos do TypeScript, podemos criar APIs fluidas que guiam o desenvolvedor através do processo de construção, prevenindo erros em tempo de compilação e garantindo que objetos sejam construídos corretamente.

Referências