Pular para o conteúdo
TypeScript

Builder Pattern Type-Safe em TypeScript

Construa objetos complexos sem medo. O compilador avisa se algum campo obrigatório ficou faltando antes do código rodar.

Por que isso é importante

Builder Pattern Type-Safe em TypeScript. Construa objetos complexos sem medo. O compilador avisa se algum campo obrigatório ficou faltando antes do código rodar.

Por que Builder ao Invés de Construtor

Construtores com muitos parâmetros são um pesadelo de manutenção. Olha esse exemplo clássico:

class User {
  constructor(
    public name: string,
    public email: string,
    public age: number,
    public address: string,
    public phone: string,
    public role: string,
    public isActive: boolean,
    public createdAt: Date
  ) {}
}

// Usar isso é horrível
const user = new User(
  "João",
  "joao@email.com",
  25,
  "Rua ABC, 123",
  "11999999999",
  "admin",
  true,
  new Date()
);

// Qual é o quarto parâmetro mesmo? Phone? Address?
// Boa sorte lembrando daqui 3 meses

O problema não é só legibilidade. É manutenção. Adiciona mais um campo obrigatório? Você precisa atualizar TODAS as chamadas do construtor no projeto inteiro. Altera a ordem dos parâmetros? Quebra tudo.

Builder Pattern resolve isso com interface fluente. Cada campo vira um método nomeado. Fica óbvio o que cada valor significa:

const user = new UserBuilder()
  .setName("João")
  .setEmail("joao@email.com")
  .setAge(25)
  .setAddress("Rua ABC, 123")
  .setPhone("11999999999")
  .setRole("admin")
  .setActive(true)
  .build();

Muito mais claro, né? Você lê e entende na hora. Adiciona campo novo? Só adiciona um método no builder. Ordem não importa mais. Campos opcionais? Simples omitir o método.

Mas tem um problema: nada impede você de esquecer um campo obrigatório e chamar build() assim mesmo. Em JavaScript puro, isso explode em runtime. Em TypeScript sem tipagem avançada, também. É aí que entra a mágica dos tipos condicionais.

Builder Básico (Sem Type Safety)

Antes de partir pro builder type-safe, vamos ver a implementação ingênua. Ela funciona, mas não te protege de erros:

interface User {
  name: string;
  email: string;
  age: number;
  address?: string; // Opcional
  phone?: string;   // Opcional
}

class UserBuilder {
  private user: Partial<User> = {};

  setName(name: string): this {
    this.user.name = name;
    return this;
  }

  setEmail(email: string): this {
    this.user.email = email;
    return this;
  }

  setAge(age: number): this {
    this.user.age = age;
    return this;
  }

  setAddress(address: string): this {
    this.user.address = address;
    return this;
  }

  setPhone(phone: string): this {
    this.user.phone = phone;
    return this;
  }

  build(): User {
    // Validação em RUNTIME - péssimo
    if (!this.user.name || !this.user.email || !this.user.age) {
      throw new Error("Campos obrigatórios faltando");
    }
    return this.user as User;
  }
}

Viu o problema? O método build() aceita qualquer coisa. TypeScript não reclama se você esquecer setName():

// Compila sem erro, explode em runtime
const user = new UserBuilder()
  .setEmail("joao@email.com")
  .setAge(25)
  .build(); // Error: Campos obrigatórios faltando

Você só descobre o erro quando executa o código. Se esse build() estiver dentro de uma função que só roda em produção, azar o seu. O cliente vê erro 500 e você passa a sexta-feira debugando.

Outro problema: usar Partial no estado interno força você a fazer casting pra User no final. Isso remove toda a segurança do TypeScript. É quase como usar any.

A solução? Fazer o compilador rastrear quais campos já foram setados. Se faltou algum obrigatório, build() nem existe no tipo retornado. Vamos ver como.

Adicionando Type Safety com Generics

A estratégia é usar generics pra rastrear o estado do builder. Cada método setter retorna um novo tipo com aquele campo marcado como preenchido:

interface User {
  name: string;
  email: string;
  age: number;
  address?: string;
  phone?: string;
}

// Tipo helper: rastreia quais campos foram setados
type BuilderState<T, S extends Partial<Record<keyof T, boolean>>> = {
  data: Partial<T>;
  state: S;
};

// Tipo helper: verifica se todos campos obrigatórios foram setados
type RequiredKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];

type IsComplete<T, S> = RequiredKeys<T> extends keyof S
  ? S[RequiredKeys<T>] extends true
    ? true
    : false
  : false;

Calma, vamos destrinchar. BuilderState guarda os dados parciais E um objeto state que mapeia cada campo pra boolean (foi setado ou não).

RequiredKeys extrai só as chaves obrigatórias de T. Usa mapped types com -? pra remover o opcional, depois filtra com conditional type.

IsComplete verifica se TODAS as chaves obrigatórias estão no state como true. Se sim, retorna true. Se não, false. Esse tipo condicional é a chave de tudo.

Agora implementa o builder usando esses tipos:

class UserBuilder<S extends Partial<Record<keyof User, boolean>> = {}> {
  private data: Partial<User> = {};

  setName(name: string): UserBuilder<S & { name: true }> {
    this.data.name = name;
    return this as any;
  }

  setEmail(email: string): UserBuilder<S & { email: true }> {
    this.data.email = email;
    return this as any;
  }

  setAge(age: number): UserBuilder<S & { age: true }> {
    this.data.age = age;
    return this as any;
  }

  setAddress(address: string): UserBuilder<S & { address: true }> {
    this.data.address = address;
    return this as any;
  }

  setPhone(phone: string): UserBuilder<S & { phone: true }> {
    this.data.phone = phone;
    return this as any;
  }

  // build() só existe se IsComplete for true
  build(this: IsComplete<User, S> extends true ? this : never): User {
    return this.data as User;
  }
}

Cada setter retorna UserBuilder com o generic S atualizado. Se você chamou setName(), o tipo agora é UserBuilder<{ name: true }>. Chamou setEmail() depois? Vira UserBuilder<{ name: true; email: true }>.

O truque do this no build() é genial. Ele força o tipo do contexto (this) a ser never se IsComplete não for true. Resultado? Se faltou campo obrigatório, build() não existe no tipo.

Agora testa:

// ✅ Compila - todos campos obrigatórios setados
const user1 = new UserBuilder()
  .setName("João")
  .setEmail("joao@email.com")
  .setAge(25)
  .build();

// ❌ Erro de compilação - faltou setAge()
const user2 = new UserBuilder()
  .setName("João")
  .setEmail("joao@email.com")
  .build(); // Property 'build' does not exist

// ✅ Compila - campos opcionais podem ser omitidos
const user3 = new UserBuilder()
  .setName("Maria")
  .setEmail("maria@email.com")
  .setAge(30)
  .setAddress("Rua XYZ") // Opcional
  .build();

Perfeito. O editor mostra erro vermelho se você tentar chamar build() sem todos os campos obrigatórios. Autocomplete só sugere build() quando o objeto está completo. É uma experiência de desenvolvedor impecável.

O Truque do This Condicional

Vamos olhar mais de perto esse truque que faz build() desaparecer quando falta campo:

build(this: IsComplete<User, S> extends true ? this : never): User {
  return this.data as User;
}

O parâmetro this em TypeScript é especial. Ele define o tipo mínimo necessário pro contexto onde o método é chamado. Não é um parâmetro de verdade - é só type annotation.

Quando você faz this: never, tá dizendo que esse método só pode ser chamado em um contexto que nunca existe. Resultado? TypeScript remove o método do tipo.

O conditional type IsComplete extends true ? this : never funciona assim:

Se todos campos obrigatórios foram setados (IsComplete retorna true), o tipo do this é... this mesmo. O método existe normalmente.

Se faltou algum campo (IsComplete retorna false), o tipo do this vira never. O método desaparece do autocomplete e gera erro se você tentar chamar.

Vamos ver o fluxo completo de tipos:

const builder = new UserBuilder(); // UserBuilder<{}>

const b1 = builder.setName("João"); // UserBuilder<{ name: true }>
const b2 = b1.setEmail("joao@email.com"); // UserBuilder<{ name: true; email: true }>

// Nesse ponto:
// IsComplete<User, { name: true; email: true }> === false
// porque age ainda não foi setado
// Então build() tem tipo: build(this: never): User
// Logo build() não existe no tipo

b2.build(); // ❌ Erro: Property 'build' does not exist

const b3 = b2.setAge(25); // UserBuilder<{ name: true; email: true; age: true }>

// Agora:
// IsComplete<User, { name: true; email: true; age: true }> === true
// Então build() tem tipo: build(this: this): User
// Logo build() existe e retorna User

b3.build(); // ✅ Funciona!

Sacou? O tipo do builder evolui conforme você chama os setters. Cada chamada adiciona uma chave no generic S. Quando S finalmente contém todas as chaves obrigatórias, IsComplete vira true e build() aparece.

É type-level programming puro. Você programa o comportamento usando só o sistema de tipos. Nenhuma validação em runtime necessária - o compilador faz tudo.

Alternativa mais simples (mas menos elegante) é ter dois métodos: buildUnsafe() que sempre existe, e build() que só existe quando completo. Aí você usa buildUnsafe() pra casos especiais onde precisa construir objeto parcial.

Builder Pattern na Prática

Vamos ver exemplos reais onde builder type-safe brilha. Primeiro: query builders pra APIs.

QueryBuilder Type-Safe

interface Query {
  table: string;
  columns: string[];
  where?: Record<string, any>;
  orderBy?: { column: string; direction: 'ASC' | 'DESC' };
  limit?: number;
}

class QueryBuilder<S extends Partial<Record<keyof Query, boolean>> = {}> {
  private query: Partial<Query> = {};

  from(table: string): QueryBuilder<S & { table: true }> {
    this.query.table = table;
    return this as any;
  }

  select(...columns: string[]): QueryBuilder<S & { columns: true }> {
    this.query.columns = columns;
    return this as any;
  }

  where(conditions: Record<string, any>): QueryBuilder<S & { where: true }> {
    this.query.where = conditions;
    return this as any;
  }

  orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): QueryBuilder<S & { orderBy: true }> {
    this.query.orderBy = { column, direction };
    return this as any;
  }

  limit(value: number): QueryBuilder<S & { limit: true }> {
    this.query.limit = value;
    return this as any;
  }

  build(this: IsComplete<Query, S> extends true ? this : never): Query {
    return this.query as Query;
  }
}

// ✅ Uso correto
const query = new QueryBuilder()
  .from('users')
  .select('id', 'name', 'email')
  .where({ active: true })
  .orderBy('name', 'ASC')
  .limit(10)
  .build();

// ❌ Erro - faltou select()
const badQuery = new QueryBuilder()
  .from('users')
  .where({ active: true })
  .build(); // Property 'build' does not exist

Repara como o compilador força você a especificar tabela E colunas antes de executar a query. Sem isso, a query SQL seria inválida. Você detecta o erro em tempo de desenvolvimento, não quando o banco retorna erro.

FormBuilder para Validação

interface FormConfig {
  fields: Record<string, FieldConfig>;
  onSubmit: (data: any) => void;
  validation?: ValidationRules;
}

interface FieldConfig {
  type: 'text' | 'email' | 'password' | 'number';
  label: string;
  required?: boolean;
}

type ValidationRules = Record<string, (value: any) => boolean>;

class FormBuilder<S extends Partial<Record<keyof FormConfig, boolean>> = {}> {
  private config: Partial<FormConfig> = {};

  addFields(fields: Record<string, FieldConfig>): FormBuilder<S & { fields: true }> {
    this.config.fields = fields;
    return this as any;
  }

  onSubmit(handler: (data: any) => void): FormBuilder<S & { onSubmit: true }> {
    this.config.onSubmit = handler;
    return this as any;
  }

  withValidation(rules: ValidationRules): FormBuilder<S & { validation: true }> {
    this.config.validation = rules;
    return this as any;
  }

  build(this: IsComplete<FormConfig, S> extends true ? this : never): FormConfig {
    return this.config as FormConfig;
  }
}

// ✅ Formulário completo
const loginForm = new FormBuilder()
  .addFields({
    email: { type: 'email', label: 'Email', required: true },
    password: { type: 'password', label: 'Senha', required: true }
  })
  .onSubmit((data) => console.log('Login:', data))
  .build();

// ❌ Erro - faltou onSubmit
const incompleteForm = new FormBuilder()
  .addFields({ name: { type: 'text', label: 'Nome' } })
  .build(); // Property 'build' does not exist

Aqui você garante que todo formulário tem campos E handler de submit. Validação é opcional. O tipo reflete exatamente as regras de negócio.

ConfigBuilder para Aplicações

interface AppConfig {
  apiUrl: string;
  apiKey: string;
  timeout: number;
  retries?: number;
  debug?: boolean;
}

class ConfigBuilder<S extends Partial<Record<keyof AppConfig, boolean>> = {}> {
  private config: Partial<AppConfig> = {};

  setApiUrl(url: string): ConfigBuilder<S & { apiUrl: true }> {
    this.config.apiUrl = url;
    return this as any;
  }

  setApiKey(key: string): ConfigBuilder<S & { apiKey: true }> {
    this.config.apiKey = key;
    return this as any;
  }

  setTimeout(ms: number): ConfigBuilder<S & { timeout: true }> {
    this.config.timeout = ms;
    return this as any;
  }

  setRetries(count: number): ConfigBuilder<S & { retries: true }> {
    this.config.retries = count;
    return this as any;
  }

  enableDebug(): ConfigBuilder<S & { debug: true }> {
    this.config.debug = true;
    return this as any;
  }

  build(this: IsComplete<AppConfig, S> extends true ? this : never): AppConfig {
    return this.config as AppConfig;
  }
}

// ✅ Config válida
const config = new ConfigBuilder()
  .setApiUrl('https://api.example.com')
  .setApiKey('abc123')
  .setTimeout(5000)
  .enableDebug()
  .build();

// ❌ Erro - faltou apiKey
const badConfig = new ConfigBuilder()
  .setApiUrl('https://api.example.com')
  .setTimeout(5000)
  .build(); // Property 'build' does not exist

ConfigBuilder é ótimo pra aplicações que precisam de setup complexo. Você força que credenciais sejam fornecidas antes de inicializar a app. Debug e retries são opcionais - refletem a interface.

Builder vs Alternativas

Builder Pattern não é solução universal. Vamos comparar com outras abordagens pra você saber quando usar cada uma.

Comparação de Patterns de Criação

Builder Pattern

+ Prós

  • • Interface fluente e legível
  • • Type safety completa com TypeScript
  • • Ordem flexível dos parâmetros
  • • Fácil adicionar novos campos opcionais

− Contras

  • • Mais verboso que object literal
  • • Requer manutenção do builder junto com a interface
  • • Overhead de código pra tipos simples

Factory Pattern

+ Prós

  • • Abstrai lógica de criação complexa
  • • Útil quando precisa retornar subclasses diferentes
  • • Centraliza criação de objetos relacionados

− Contras

  • • Menos flexível que builder pra configuração
  • • Pode esconder complexidade demais
  • • Dificulta saber quais parâmetros são necessários

Constructor com Parâmetros

+ Prós

  • • Simples e direto
  • • Sem código extra necessário
  • • Performance ótima

− Contras

  • • Ilegível com muitos parâmetros
  • • Ordem rígida dos parâmetros
  • • Difícil manutenção quando adiciona campos

Object Literal

+ Prós

  • • Sintaxe concisa
  • • Ordem irrelevante
  • • TypeScript valida campos obrigatórios

− Contras

  • • Sem validação customizada
  • • Não suporta lógica de criação complexa
  • • Dificulta valores padrão condicionais

Exemplo prático comparando as abordagens:

// 1. Object Literal - Melhor pra objetos simples
const user1: User = {
  name: "João",
  email: "joao@email.com",
  age: 25,
  address: "Rua ABC"
};

// 2. Constructor - OK pra 2-3 params
class Point {
  constructor(public x: number, public y: number) {}
}
const point = new Point(10, 20);

// 3. Factory - Quando precisa lógica condicional
class UserFactory {
  static create(type: 'admin' | 'user', data: Partial<User>): User {
    if (type === 'admin') {
      return { ...data, role: 'admin', permissions: ['all'] } as User;
    }
    return { ...data, role: 'user', permissions: ['read'] } as User;
  }
}
const admin = UserFactory.create('admin', { name: 'João', email: 'joao@email.com', age: 25 });

// 4. Builder - Melhor pra objetos complexos com validação
const user4 = new UserBuilder()
  .setName("João")
  .setEmail("joao@email.com")
  .setAge(25)
  .setAddress("Rua ABC")
  .build();

Use builder quando: objeto tem muitos campos (5+), alguns obrigatórios e outros opcionais, você quer validação em compile-time, ou precisa construir o objeto em etapas.

Use object literal quando: objeto é simples, todos os campos são conhecidos de antemão, não precisa validação especial.

Use factory quando: precisa criar diferentes subtipos baseado em lógica, quer esconder complexidade de criação, ou trabalha com famílias de objetos relacionados.

Use constructor quando: objeto é super simples (2-3 campos), ordem dos parâmetros é óbvia, e não vai mudar com frequência.

Patterns Complementares

Builder Pattern funciona ainda melhor quando combinado com outros patterns. Vamos ver os principais.

Step Builder Pattern

Step Builder força uma ordem específica de construção. Cada método retorna uma interface diferente que só expõe o próximo método válido:

interface User {
  name: string;
  email: string;
  age: number;
}

// Cada step expõe só o próximo método válido
interface NameStep {
  setName(name: string): EmailStep;
}

interface EmailStep {
  setEmail(email: string): AgeStep;
}

interface AgeStep {
  setAge(age: number): BuildStep;
}

interface BuildStep {
  build(): User;
}

class UserStepBuilder implements NameStep, EmailStep, AgeStep, BuildStep {
  private user: Partial<User> = {};

  setName(name: string): EmailStep {
    this.user.name = name;
    return this;
  }

  setEmail(email: string): AgeStep {
    this.user.email = email;
    return this;
  }

  setAge(age: number): BuildStep {
    this.user.age = age;
    return this;
  }

  build(): User {
    return this.user as User;
  }
}

// ✅ Uso forçado na ordem correta
const user = new UserStepBuilder()
  .setName("João")      // Só setName() disponível
  .setEmail("joao@email.com") // Só setEmail() disponível
  .setAge(25)           // Só setAge() disponível
  .build();             // Só build() disponível

// ❌ Impossível chamar fora de ordem
// new UserStepBuilder().setEmail(...) // Erro: não existe no tipo NameStep

Step Builder é perfeito quando a ordem importa. Por exemplo: construir requests HTTP (URL → headers → body → send), ou pipelines de processamento (input → transform → validate → output).

Fluent Interface

Fluent Interface é o padrão de retornar this pra permitir method chaining. Builder usa isso naturalmente, mas dá pra aplicar em qualquer classe:

class StringBuilder {
  private parts: string[] = [];

  append(text: string): this {
    this.parts.push(text);
    return this;
  }

  appendLine(text: string): this {
    this.parts.push(text + '\n');
    return this;
  }

  prepend(text: string): this {
    this.parts.unshift(text);
    return this;
  }

  clear(): this {
    this.parts = [];
    return this;
  }

  toString(): string {
    return this.parts.join('');
  }
}

const html = new StringBuilder()
  .append('<div>')
  .appendLine('  <h1>Título</h1>')
  .appendLine('  <p>Parágrafo</p>')
  .append('</div>')
  .toString();

Repara no return this em cada método. Isso permite encadear chamadas. Fica muito mais legível que chamar cada método separado.

Director Pattern

Director encapsula a lógica de construção complexa. Você passa um builder pro director e ele orquestra a criação:

interface EmailBuilder {
  setRecipient(email: string): this;
  setSubject(subject: string): this;
  setBody(body: string): this;
  addAttachment(file: string): this;
  build(): Email;
}

class EmailDirector {
  constructor(private builder: EmailBuilder) {}

  buildWelcomeEmail(userEmail: string, userName: string): Email {
    return this.builder
      .setRecipient(userEmail)
      .setSubject(`Bem-vindo, ${userName}!`)
      .setBody(`Olá ${userName}, obrigado por se cadastrar...`)
      .addAttachment('welcome-guide.pdf')
      .build();
  }

  buildPasswordResetEmail(userEmail: string, resetLink: string): Email {
    return this.builder
      .setRecipient(userEmail)
      .setSubject('Resetar sua senha')
      .setBody(`Clique aqui para resetar: ${resetLink}`)
      .build();
  }

  buildInvoiceEmail(userEmail: string, invoice: Invoice): Email {
    return this.builder
      .setRecipient(userEmail)
      .setSubject(`Fatura #${invoice.id}`)
      .setBody(`Sua fatura de ${invoice.amount}...`)
      .addAttachment(`invoice-${invoice.id}.pdf`)
      .build();
  }
}

const builder = new ConcreteEmailBuilder();
const director = new EmailDirector(builder);

const welcomeEmail = director.buildWelcomeEmail('user@example.com', 'João');
const resetEmail = director.buildPasswordResetEmail('user@example.com', 'https://reset...');

Director é útil quando você tem várias "receitas" de construção predefinidas. Em vez de espalhar a lógica de construção pelo código, centraliza tudo no director.

Combinar builder type-safe + director dá o melhor dos dois mundos: flexibilidade pra criar objetos customizados E conveniência de métodos pré-configurados pra casos comuns.

Quando Usar Builder Pattern

  • Objeto tem 5+ campos, vários obrigatórios
  • Ordem dos parâmetros não é óbvia
  • Precisa validação em compile-time
  • Objeto é construído em etapas/passos
  • Campos opcionais mudam com frequência
  • Quer interface fluente e legível
  • Configuração complexa que muda por ambiente
  • Precisa garantir estado consistente antes de uso

Conclusão

Builder Pattern type-safe transforma TypeScript de sistema de tipos básico em ferramenta de validação poderosa. Você programa as regras de negócio diretamente nos tipos - o compilador faz o resto.

A combinação de generics, conditional types e this parameter cria uma experiência de desenvolvimento impecável. O editor guia você, autocomplete só sugere métodos válidos, e erros aparecem antes de compilar.

Use builder quando legibilidade e type safety importam mais que concisão. Em projetos grandes, a proteção extra compensa o código adicional. Você passa menos tempo debugando erros bobos e mais tempo construindo features.

Comece simples: implemente um builder pra um objeto complexo do seu projeto. Adicione type safety incremental. Conforme você vê os benefícios, expanda pra outros lugares. Em poucos meses, você não consegue mais viver sem.