Pular para o conteúdo
TypeScript

Design Patterns Tipados em TypeScript

Domine os 5 design patterns mais usados no TypeScript. Singleton, Factory, Observer, Strategy e Repository, todos tipados com exemplos de produção.

Por que isso é importante

Design Patterns Tipados em TypeScript. Domine os 5 design patterns mais usados no TypeScript. Singleton, Factory, Observer, Strategy e Repository, todos tipados com exemplos de produção.

Por Que Tipar Design Patterns

Design patterns resolvem problemas recorrentes de arquitetura. Mas em linguagens dinâmicas, a implementação depende da disciplina do dev. Nada impede alguém de passar um objeto errado pro Observer ou de instanciar o Singleton duas vezes.

Com TypeScript, os contratos do pattern ficam explícitos no código. Interfaces definem o que cada participante precisa implementar. Generics tornam os patterns reutilizáveis sem perder tipo. O compilador vira o fiscal que nunca dorme.

Vamos tipar os 5 patterns mais usados no dia a dia: Singleton, Factory, Observer, Strategy e Repository. Cada um com interface, implementação e exemplo de uso. Código que você copia e adapta pro seu projeto.

Os 5 Patterns Tipados Passo a Passo

Cada pattern segue a mesma estrutura: problema, interface, implementação tipada e uso.

  1. Passo 1 - Singleton: Garanta uma única instância com construtor privado e método estático tipado. O TypeScript impede new fora da classe.
  2. Passo 2 - Factory: Use generics e interfaces pra criar objetos sem expor a lógica de criação. A Factory retorna o tipo correto baseado no parâmetro.
  3. Passo 3 - Observer: Genéricos no evento garantem que subscribers recebem o tipo certo de payload. Sem any em nenhum lugar.
  4. Passo 4 - Strategy: Interface define o contrato. Cada strategy implementa. O contexto aceita qualquer implementação que siga a interface.
  5. Passo 5 - Repository: Interface genérica de CRUD. Implementações concretas (SQL, Mongo, in-memory) seguem o mesmo contrato tipado.

Exemplos Práticos dos Design Patterns

Vamos pro código. Cada pattern com implementação completa e tipagem forte.

Singleton: Uma Instância, Tipo Garantido

class Database {
  private static instance: Database | null = null;
  private connections: number = 0;

  // Construtor privado impede new Database()
  private constructor(private readonly url: string) {}

  static getInstance(url: string): Database {
    if (!Database.instance) {
      Database.instance = new Database(url);
    }
    return Database.instance;
  }

  connect(): void {
    this.connections++;
    console.log(`Conexão ${this.connections} em ${this.url}`);
  }

  getStatus(): { url: string; connections: number } {
    return { url: this.url, connections: this.connections };
  }
}

// Uso
const db1 = Database.getInstance("mongodb://localhost");
const db2 = Database.getInstance("mongodb://localhost");
console.log(db1 === db2); // true - mesma instância

// new Database("url"); // Error: Constructor is private

Factory: Criação Tipada de Objetos

// Interface do produto
interface Notificacao {
  enviar(destino: string, mensagem: string): Promise<boolean>;
  readonly tipo: string;
}

// Implementações concretas
class EmailNotificacao implements Notificacao {
  readonly tipo = "email";
  async enviar(destino: string, mensagem: string): Promise<boolean> {
    console.log(`Email para ${destino}: ${mensagem}`);
    return true;
  }
}

class SmsNotificacao implements Notificacao {
  readonly tipo = "sms";
  async enviar(destino: string, mensagem: string): Promise<boolean> {
    console.log(`SMS para ${destino}: ${mensagem}`);
    return true;
  }
}

class PushNotificacao implements Notificacao {
  readonly tipo = "push";
  async enviar(destino: string, mensagem: string): Promise<boolean> {
    console.log(`Push para ${destino}: ${mensagem}`);
    return true;
  }
}

// Mapa de tipos pra factory
type TipoNotificacao = "email" | "sms" | "push";

const notificacaoMap: Record<TipoNotificacao, new () => Notificacao> = {
  email: EmailNotificacao,
  sms: SmsNotificacao,
  push: PushNotificacao,
};

// Factory function tipada
function criarNotificacao(tipo: TipoNotificacao): Notificacao {
  const Classe = notificacaoMap[tipo];
  return new Classe();
}

// Uso
const notif = criarNotificacao("email");
await notif.enviar("joao@mail.com", "Olá!");
// criarNotificacao("whatsapp"); // Error: tipo inválido

Observer: Eventos com Payload Tipado

// Mapa de eventos tipados
interface EventMap {
  "user:created": { id: number; name: string };
  "user:deleted": { id: number };
  "order:placed": { orderId: string; total: number };
}

// Observer genérico e tipado
class EventBus {
  private listeners: {
    [K in keyof EventMap]?: Array<(payload: EventMap[K]) => void>;
  } = {};

  on<K extends keyof EventMap>(
    event: K,
    callback: (payload: EventMap[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    const callbacks = this.listeners[event];
    if (callbacks) {
      callbacks.forEach(cb => cb(payload));
    }
  }

  off<K extends keyof EventMap>(
    event: K,
    callback: (payload: EventMap[K]) => void
  ): void {
    const callbacks = this.listeners[event];
    if (callbacks) {
      this.listeners[event] = callbacks.filter(cb => cb !== callback);
    }
  }
}

// Uso - tudo tipado!
const bus = new EventBus();

bus.on("user:created", (payload) => {
  // payload é { id: number; name: string } automaticamente
  console.log(`Novo user: ${payload.name}`);
});

bus.emit("user:created", { id: 1, name: "Ana" }); // OK
// bus.emit("user:created", { id: 1 }); // Error: falta name

Strategy: Algoritmos Intercambiáveis

// Interface da strategy
interface PricingStrategy {
  calcular(preco: number, quantidade: number): number;
  readonly nome: string;
}

// Implementações concretas
class PrecoNormal implements PricingStrategy {
  readonly nome = "normal";
  calcular(preco: number, quantidade: number): number {
    return preco * quantidade;
  }
}

class PrecoBlackFriday implements PricingStrategy {
  readonly nome = "black-friday";
  calcular(preco: number, quantidade: number): number {
    const desconto = quantidade >= 3 ? 0.3 : 0.1;
    return preco * quantidade * (1 - desconto);
  }
}

class PrecoVIP implements PricingStrategy {
  readonly nome = "vip";
  calcular(preco: number, quantidade: number): number {
    return preco * quantidade * 0.8; // 20% off sempre
  }
}

// Contexto que usa a strategy
class Carrinho {
  private items: Array<{ nome: string; preco: number; qtd: number }> = [];

  constructor(private strategy: PricingStrategy) {}

  setStrategy(strategy: PricingStrategy): void {
    this.strategy = strategy;
  }

  addItem(nome: string, preco: number, qtd: number): void {
    this.items.push({ nome, preco, qtd });
  }

  getTotal(): number {
    return this.items.reduce(
      (total, item) => total + this.strategy.calcular(item.preco, item.qtd),
      0
    );
  }
}

// Uso
const carrinho = new Carrinho(new PrecoNormal());
carrinho.addItem("Camiseta", 50, 2);
console.log(carrinho.getTotal()); // 100

carrinho.setStrategy(new PrecoBlackFriday());
console.log(carrinho.getTotal()); // 90 (10% off, menos de 3 itens)

Repository: CRUD Genérico Tipado

// Entidade base
interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

// Interface genérica do Repository
interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>;
  findAll(filter?: Partial<T>): Promise<T[]>;
  create(data: Omit<T, "id" | "createdAt" | "updatedAt">): Promise<T>;
  update(id: string, data: Partial<Omit<T, "id">>): Promise<T | null>;
  delete(id: string): Promise<boolean>;
}

// Entidade concreta
interface User extends Entity {
  name: string;
  email: string;
  role: "admin" | "user";
}

// Implementação in-memory (teste/prototipagem)
class InMemoryUserRepo implements Repository<User> {
  private users: User[] = [];

  async findById(id: string): Promise<User | null> {
    return this.users.find(u => u.id === id) ?? null;
  }

  async findAll(filter?: Partial<User>): Promise<User[]> {
    if (!filter) return [...this.users];
    return this.users.filter(u =>
      Object.entries(filter).every(([k, v]) => u[k as keyof User] === v)
    );
  }

  async create(data: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User> {
    const user: User = {
      ...data,
      id: crypto.randomUUID(),
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    this.users.push(user);
    return user;
  }

  async update(id: string, data: Partial<Omit<User, "id">>): Promise<User | null> {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return null;
    this.users[index] = { ...this.users[index], ...data, updatedAt: new Date() };
    return this.users[index];
  }

  async delete(id: string): Promise<boolean> {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return false;
    this.users.splice(index, 1);
    return true;
  }
}

// Uso - o tipo User garante segurança total
const repo: Repository<User> = new InMemoryUserRepo();
const user = await repo.create({ name: "Ana", email: "ana@mail.com", role: "admin" });
// user.name é string, user.role é "admin" | "user" - tudo tipado

Cada pattern vira um contrato que o TypeScript fiscaliza. Factory retorna o tipo certo. Observer entrega o payload correto. Repository aceita só campos válidos. Sem surpresas em runtime.

Erros Comuns ao Tipar Design Patterns

Armadilhas que enfraquecem seus patterns

Singleton sem construtor privado: se o construtor é público, qualquer parte do código pode criar novas instâncias. O pattern perde o sentido. Use private constructor obrigatoriamente.

Factory que retorna any: uma Factory que retorna any em vez do tipo específico elimina toda a vantagem da tipagem. Use interfaces e generics pra garantir o tipo de retorno.

Observer sem EventMap: se o sistema de eventos aceita qualquer string como nome e any como payload, você tá de volta ao JavaScript. Defina um mapa de eventos tipado.

Strategy com implementação incompleta: se uma implementação não segue todos os métodos da interface, o TypeScript acusa erro. Não use // @ts-ignore pra contornar. Implemente tudo.

Repository sem Omit nos campos automáticos: se o método create aceita o tipo completo incluindo id e createdAt, quem chama precisa inventar esses valores. Use Omit pra excluir campos que são gerados internamente.

Checklist de Design Patterns Tipados

  • Singleton com construtor privado e getInstance tipado
  • Factory retorna interface específica, não any
  • Observer usa EventMap genérico com payload tipado por evento
  • Strategy define interface com todos os métodos obrigatórios
  • Repository usa generics com Entity base e Omit nos métodos de criação
  • Nenhum as ou any usado pra contornar erros de tipo
  • Todas as implementações concretas passam no type check
  • Testes unitários cobrem os contratos de cada pattern

TypeScript Avançado na Prática

Design patterns tipados são o que separa código amador de código profissional. Mas a mágica acontece quando você aplica Singleton, Factory, Observer, Strategy e Repository juntos num projeto real com camadas bem definidas. No CrazyStack, você constrói um SaaS completo com Node.js, React e TypeScript, aplicando cada pattern no contexto certo.

Se você quer sair da teoria e ver esses patterns funcionando em produção com tipagem de ponta a ponta, esse é o próximo passo.