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
- TypeScript Handbook: Classes — Documentação oficial sobre classes e métodos em TypeScript, base para implementação de builders
- Refactoring Guru: Builder Pattern — Guia completo sobre o padrão Builder com exemplos em TypeScript
- TypeScript Deep Dive: Builder Pattern — Tutorial aprofundado sobre implementação do Builder Pattern em TypeScript
- Microsoft DevBlogs: Advanced TypeScript Patterns — Padrões avançados de TypeScript que podem ser aplicados em builders
- TypeScript Generics Documentation — Documentação oficial sobre tipos genéricos, essenciais para implementar builders flexíveis
- Patterns.dev: Builder Pattern — Tutorial moderno sobre o padrão Builder com exemplos práticos em TypeScript