TypeScriptReactPadrões de design funcionaisTypeScript design patternsComo usar design patterns em React
Adeus, Classes? Padrões de Design funcionais em TypeScript e React

Rocketseat

Conheça o Rocketseat Para Empresas
Oferecemos soluções personalizadas para empresas de todos os portes.
Se você já trabalhou em uma codebase moderna de React, sabe como é: componentes funcionais, hooks, composição por todo lado. Aí chega aquele momento em que você precisa implementar um padrão de design clássico e se pergunta:
Como faço isso sem criar um monte de classes? 🤔
A verdade é que existe uma lacuna entre os padrões orientados a objetos que aprendemos nos livros e a realidade do código funcional que escrevemos todo dia. A boa notícia? TypeScript oferece alternativas funcionais elegantes para os padrões clássicos, aproveitando funções de primeira classe, composição e closures.
Bora explorar como implementar três dos padrões mais comuns (Strategy, Factory e Singleton) de forma funcional, sem forçar o uso de classes onde elas não fazem sentido! 🚀
O desafio: padrões OO em código funcional
Os padrões de design clássicos nasceram no mundo da programação orientada a objetos. O livro "Design Patterns" (Gang of Four) foi escrito quando Smalltalk e C++ dominavam o mercado. Naturalmente, todos os exemplos usam classes, herança e polimorfismo.
O problema surge quando você trabalha em uma codebase que prioriza funções. No ecossistema JavaScript/TypeScript moderno, especialmente com React, a programação funcional é a norma. Functional components, hooks, estado imutável, funções puras. Aliás, dominar essa abordagem é o que separa as interfaces reativas medianas das excelentes. Se você quer levar suas habilidades em React para esse nível, construir projetos do zero ao avançado é o caminho ideal.
Tentar aplicar padrões OO clássicos nesse contexto cria uma dissonância. Você acaba escrevendo classes apenas para satisfazer o padrão, adicionando boilerplate desnecessário e tornando o código menos idiomático. É como tentar encaixar uma peça quadrada em um buraco redondo.
A questão não é se os padrões são úteis (são!), mas como adaptá-los para o paradigma funcional que TypeScript e JavaScript abraçam tão bem.
Alternativa funcional: strategy pattern
O Strategy Pattern tradicional usa interfaces e classes para encapsular algoritmos intercambiáveis. Em TypeScript funcional, isso se torna muito mais simples: strategies são apenas funções.
A versão orientada a objetos
interface PaymentStrategy { processPayment(amount: number): void; } class PayPalPayment implements PaymentStrategy { processPayment(amount: number): void { console.log(`Processando pagamento PayPal de R$${amount}`); } } class CreditCardPayment implements PaymentStrategy { processPayment(amount: number): void { console.log(`Processando pagamento com cartão de R$${amount}`); } } class OnlineStore { private paymentStrategy: PaymentStrategy; constructor(paymentStrategy: PaymentStrategy) { this.paymentStrategy = paymentStrategy; } setPaymentStrategy(strategy: PaymentStrategy): void { this.paymentStrategy = strategy; } checkout(amount: number): void { this.paymentStrategy.processPayment(amount); } } // Uso: muitas linhas de setup const store = new OnlineStore(new PayPalPayment()); store.checkout(100); store.setPaymentStrategy(new CreditCardPayment()); store.checkout(200);
A versão funcional
// Estratégias são apenas funções type PaymentStrategy = (amount: number) => void; const processPayPalPayment: PaymentStrategy = (amount) => { console.log(`Processando pagamento PayPal de R$${amount}`); }; const processCreditCardPayment: PaymentStrategy = (amount) => { console.log(`Processando pagamento com cartão de R$${amount}`); }; // Higher-order function para processar pagamento const processPayment = ( strategy: PaymentStrategy, amount: number ): void => { strategy(amount); }; // Uso: simples e direto processPayment(processPayPalPayment, 100); processPayment(processCreditCardPayment, 200); // Ou com seleção dinâmica const paymentStrategies: Record<string, PaymentStrategy> = { paypal: processPayPalPayment, creditCard: processCreditCardPayment, }; const selectedMethod = 'paypal'; processPayment(paymentStrategies[selectedMethod], 150);
A diferença é notável: redução significativa de código, zero boilerplate, e a mesma funcionalidade. Além disso, a versão funcional aproveita as características nativas do JavaScript: funções são cidadãos de primeira classe, podem ser armazenadas em objetos, arrays, passadas como argumentos. Não precisamos simular callbacks com objetos.
Quando usar a abordagem funcional para strategy
A abordagem funcional brilha quando suas estratégias são stateless (sem estado interno). Se você precisa apenas de algoritmos intercambiáveis que operam sobre os dados que recebem, funções são perfeitas.
Exemplo prático: cálculo de frete com diferentes transportadoras.
type ShippingCalculator = (weightInKg: number, distanceInKm: number) => number; const calculateFedExShipping: ShippingCalculator = (weightInKg, distanceInKm) => { const weightCost = weightInKg * 0.5; const distanceCost = distanceInKm * 0.1; return weightCost + distanceCost; }; const calculateUPSShipping: ShippingCalculator = (weightInKg, distanceInKm) => { const weightCost = weightInKg * 0.45; const distanceCost = distanceInKm * 0.12; return weightCost + distanceCost; }; const calculateShippingCost = ( calculator: ShippingCalculator, weightInKg: number, distanceInKm: number ) => calculator(weightInKg, distanceInKm); // Uso const shippingCost = calculateShippingCost(calculateFedExShipping, 10, 500); console.log(`Custo de envio: R$${shippingCost}`);
Alternativa funcional: factory pattern
O Factory Pattern tradicional cria objetos através de classes especializadas. Factory functions em TypeScript fazem o mesmo, mas usando closures para encapsulamento.
A versão orientada a objetos
interface Notification { send(message: string): void; } class EmailNotification implements Notification { send(message: string): void { console.log(`Enviando email: ${message}`); } } class SMSNotification implements Notification { send(message: string): void { console.log(`Enviando SMS: ${message}`); } } class NotificationFactory { createNotification(type: string): Notification { if (type === 'EMAIL') { return new EmailNotification(); } else if (type === 'SMS') { return new SMSNotification(); } throw new Error('Tipo de notificação não suportado'); } } // Uso const factory = new NotificationFactory(); const emailNotif = factory.createNotification('EMAIL'); emailNotif.send('Olá Mayk!');
A versão funcional
Factory functions são funções que criam e retornam objetos. Simples assim. Elas usam closures para criar variáveis verdadeiramente privadas, sem necessidade de
new, this ou herança baseada em protótipos.// Factory function simples function createUser(name: string) { // Closure mantém dados privados const createdAt = new Date(); return { name, greet() { console.log(`Olá, eu sou ${name}`); }, getAccountAge() { const now = new Date(); const ageInYears = now.getFullYear() - createdAt.getFullYear(); return ageInYears; } }; } // Uso - sem new! const user = createUser('Rodrigo'); user.greet(); // Olá, eu sou Rodrigo
Um exemplo mais completo com composição de funcionalidades:
// Factory base para personagem function createCharacter(name: string, initialHitPoints: number) { let currentHitPoints = initialHitPoints; return { name, getHitPoints() { return currentHitPoints; }, takeDamage(damageAmount: number) { currentHitPoints -= damageAmount; console.log(`${name} sofreu ${damageAmount} de dano`); return this; }, isAlive() { return currentHitPoints > 0; } }; } // Factory para jogador que compõe o character function createPlayer(name: string, weaponName: string) { const character = createCharacter(name, 100); return { ...character, weapon: weaponName, attack(target: ReturnType<typeof createCharacter>) { const attackDamage = 20; console.log(`${name} ataca com ${weaponName}`); target.takeDamage(attackDamage); return this; } }; } // Uso com method chaining const player = createPlayer('Diego', 'Espada'); const enemy = createCharacter('Orc', 80); player.attack(enemy).attack(enemy); console.log(`Inimigo vivo? ${enemy.isAlive()}`);
As vantagens das factory functions
Factory functions oferecem encapsulamento real através de closures. Diferente de propriedades privadas de classes (que são apenas convenção ou syntax sugar), variáveis dentro de closures são verdadeiramente inacessíveis:
function createBankAccount(initialBalance: number) { // Variável REALMENTE privada via closure let balance = initialBalance; return { deposit(amount: number) { balance += amount; }, withdraw(amount: number) { if (amount <= balance) { balance -= amount; } }, getBalance() { return balance; } // balance não é acessível diretamente! }; } const account = createBankAccount(1000); account.deposit(500); console.log(account.getBalance()); // 1500 console.log(account.balance); // undefined - privado de verdade!
Além disso, factory functions favorecem composição sobre herança. Você pode facilmente combinar funcionalidades de múltiplas fontes sem criar hierarquias complexas de classes:
// Mixin funcional function withMagicAbilities<T extends { name: string }>(character: T) { let currentMana = 100; return { ...character, getMana() { return currentMana; }, castSpell(spellName: string) { const manaCost = 20; if (currentMana >= manaCost) { console.log(`${character.name} conjura ${spellName}!`); currentMana -= manaCost; } else { console.log(`${character.name} não tem mana suficiente!`); } return this; } }; } // Compondo funcionalidades const player = createPlayer('Laís', 'Cajado'); const mage = withMagicAbilities(player); mage.attack(enemy).castSpell('Bola de Fogo');
Considerações sobre closures
Vale mencionar que closures, apesar de poderosas, têm algumas considerações importantes:
Performance: cada instância criada via factory function mantém suas próprias cópias de funções na memória. Para aplicações que criam milhares de objetos similares, classes podem ser mais eficientes em termos de memória, já que os métodos são compartilhados via prototype chain.
Debugging: closures podem tornar o debugging um pouco mais desafiador, já que variáveis privadas não aparecem no console ou em ferramentas de inspeção. Em classes, propriedades privadas são mais visíveis durante o debug.
Dito isso, para a maioria das aplicações modernas, esses trade-offs são aceitáveis. A simplicidade e clareza do código funcional geralmente superam essas pequenas desvantagens.
Alternativa funcional: singleton pattern
Aqui está uma revelação: você provavelmente não precisa implementar o Singleton Pattern em TypeScript. Módulos ES6 já são singletons por natureza! 🎉
A versão orientada a objetos
class DatabaseConnection { private static instance: DatabaseConnection; private connection: any; private constructor() { this.connection = {}; // lógica de conexão } public static getInstance(): DatabaseConnection { if (!DatabaseConnection.instance) { DatabaseConnection.instance = new DatabaseConnection(); } return DatabaseConnection.instance; } public query(sql: string) { console.log(`Executando query: ${sql}`); } } // Uso const db1 = DatabaseConnection.getInstance(); const db2 = DatabaseConnection.getInstance(); console.log(db1 === db2); // true - mesma instância
A versão com módulos ES6
Módulos ES6/TypeScript são executados apenas uma vez, independentemente de quantas vezes sejam importados. O sistema de cache do Node.js/V8 garante que a mesma instância seja retornada. Isso torna os módulos singletons naturais!
// databaseModule.ts class DatabaseConnection { private connection: any; constructor() { console.log('Criando conexão com banco...'); this.connection = {}; // lógica de conexão } query(sql: string) { console.log(`Executando query: ${sql}`); } } // Cria e exporta a instância única export const database = new DatabaseConnection(); // Ou sem classe, puramente funcional: const connection = createDatabaseConnection(); export function executeQuery(sql: string) { return connection.execute(sql); }
Agora, em qualquer arquivo que importar esse módulo, você terá acesso à mesma instância:
// arquivo1.ts import { database } from './databaseModule'; database.query('SELECT * FROM users'); // Usa a mesma instância// arquivo2.ts import { database } from './databaseModule'; database.query('SELECT * FROM products'); // Mesma instância!
Essa abordagem com módulos para gerenciar uma instância única, como a de um banco de dados, é extremamente poderosa no back-end. Afinal, essa é apenas uma peça no quebra-cabeça de uma API escalável. Se você tem interesse em construir toda a arquitetura por trás de uma aplicação — desde a lógica de negócio até a exposição de endpoints seguros — aplicar estes e outros padrões em um ambiente como o Node.js é o seu campo de jogo.
Por que singleton é considerado um anti-pattern
O Singleton clássico tem problemas bem documentados:
- Quebra o princípio da responsabilidade única: a classe gerencia tanto sua lógica quanto seu próprio ciclo de vida
- Dificulta testes: todos os testes compartilham a mesma instância, causando contaminação entre testes
- Dependências ocultas: hard-coded references tornam o código fortemente acoplado
- Impede dependency injection: não pode injetar mocks ou implementações alternativas
Módulos ES6 resolvem esses problemas de forma mais elegante:
// configService.ts interface AppConfig { apiUrl: string; apiKey: string; timeoutInMs: number; } let config: AppConfig = { apiUrl: 'https://api.example.com', apiKey: '12345', timeoutInMs: 5000 }; export function getConfig(): AppConfig { return { ...config }; // retorna cópia para evitar mutação externa } export function updateConfig(newConfig: Partial<AppConfig>): void { config = { ...config, ...newConfig }; } // Para testes, você pode facilmente resetar ou mockar export function resetConfig(): void { config = { apiUrl: 'https://api.example.com', apiKey: '12345', timeoutInMs: 5000 }; }
Quando usar cada abordagem
A escolha entre programação funcional e orientada a objetos não é binária. TypeScript permite ambos os paradigmas, e você pode (e deve!) usar o melhor de cada mundo.
Prefira a abordagem funcional quando
✅ Transformação de dados: pipelines de processamento, validações, formatações
✅ Estratégias stateless: algoritmos que operam apenas sobre os dados recebidos
✅ Composição é prioritária: quando você quer combinar comportamentos de forma flexível
✅ Testabilidade é crítica: funções puras são triviais de testar, sem setup complexo
✅ Codebase funcional: React, Redux, ou qualquer projeto que já usa paradigma funcional
Considere OO quando
🔷 Modelagem de domínio complexo: entidades com comportamento rico e regras de negócio encapsuladas
🔷 Estado mutável otimizado: quando performance de memória é crítica e você precisa criar muitas instâncias
🔷 Frameworks orientados a objetos: Angular, NestJS, ou bibliotecas que esperam classes
🔷 Hierarquias bem definidas: quando polimorfismo e herança realmente fazem sentido
A abordagem híbrida (recomendada!)
Na prática, a maioria das codebases TypeScript beneficia de uma abordagem híbrida:
class UserService { constructor(private readonly apiClient: ApiClient) {} // Método puro - testável e previsível validateUser(user: User): ValidationResult { return { isValid: this.isValidEmail(user.email) && this.isValidAge(user.age), errors: this.collectValidationErrors(user) }; } // Método impuro - side effect isolado async saveUser(user: User): Promise<void> { const validation = this.validateUser(user); if (!validation.isValid) { throw new Error('Usuário inválido'); } await this.apiClient.post('/users', user); } private isValidEmail(email: string): boolean { return /\S+@\S+\.\S+/.test(email); } private isValidAge(age: number): boolean { const minimumAge = 18; return age >= minimumAge; } private collectValidationErrors(user: User): string[] { const errors: string[] = []; if (!this.isValidEmail(user.email)) { errors.push('Email inválido'); } if (!this.isValidAge(user.age)) { errors.push('Idade mínima: 18 anos'); } return errors; } }
Use classes para agrupar funcionalidade relacionada, mas mantenha métodos puros sempre que possível. Separe claramente lógica pura de side effects. Favoreça composição sobre herança.
Conclusão e próximos passos
A programação funcional em TypeScript não é sobre dogmatismo ou seguir regras rígidas. É sobre escolher as ferramentas certas para cada trabalho. Os padrões de design clássicos continuam valiosos, mas suas implementações podem (e devem!) ser adaptadas para o paradigma que você está usando.
Quando você abraça funções de primeira classe, composição e imutabilidade, o código fica mais simples, testável e idiomático. Strategy Pattern vira funções intercambiáveis. Factory Pattern vira factory functions com closures. Singleton vira módulos ES6.
A boa notícia? Você não precisa reescrever toda sua codebase. Comece pequeno: use
const ao invés de let, extraia funções puras, experimente factory functions em novos recursos. A transição para código mais funcional é gradual e pragmática.O ecossistema TypeScript moderno, especialmente com React e frameworks similares, já abraçou a programação funcional. E é exatamente aqui que frameworks como o Next.js brilham. Se você quer levar essa filosofia funcional para o próximo nível e construir aplicações completas, desde o roteamento até a renderização no servidor, entender como o Next.js organiza esses conceitos é fundamental. Adaptar seus padrões de design para esse contexto não é apenas possível, é a abordagem mais natural e produtiva. 💜
E você, qual outro padrão de design tem dificuldade em aplicar no paradigma funcional? Conta para a gente lá na comunidade!
Conheça o Rocketseat Para Empresas
Oferecemos soluções personalizadas para empresas de todos os portes.
Artigos_
Explore conteúdos relacionados
Descubra mais artigos que complementam seu aprendizado e expandem seu conhecimento.
NewsletterReceba conteúdos inéditos e novidades gratuitamente
