Fundamentos da programação 11 min de leitura 15 maio 2026

Polimorfismo: O terceiro pilar da Programação Orientada a Objetos

Fala Dev, tudo em riba?

Estamos nós aqui em mais um artigo da série sobre Programação Orientada a Objetos (POO). Recapitulando, passamos pelo conceito base da POO, abordamos o primeiro pilar que é o Encapsulamento – proteção do estado interno do objeto – e o segundo pilar que é a Herança – classe filha herda características e comportamentos da classe pai acrescentando seus próprios comportamentos e características – e vimos exemplos de cada um dos pilares. Se você não leu os três primeiros artigos da série, recomendo fortemente a leitura pois todos os conceitos neles abordados servem como base para o pilar que vamos abordar neste artigo, o Polimorfismo.

Dá uma pausa na leitura desse artigo aqui e dê uma lida nos artigos anteriores.

Leu os artigos? Então bora entender o que é esse tal de Polimorfismo.

O que é o Polimorfismo?

Na POO, o polimorfismo é a capacidade de objetos de tipos diferentes responderem o mesmo método de formas distintas. Você chama o mesmo método, no mesmo “formato”, mas cada objeto executa seu próprio comportamento internamente. A palavra vem do grego poly (muitos) e morphos (formas).

Eu trabalho 99% do meu tempo com desenvolvimento backend e se você também trabalha ou tem contato com código de aplicações backend pode ter utilizado o polimorfismo de duas formas diferentes sem se dar conta – estou partindo do ponto que você querido leitor ou leitora esteja conhecendo o polimorfismo nesse exato instante. Mais à frente veremos quais são essas duas formas, mas antes precisamos saber por que deveríamos utilizar o polimorfismo.

O problema que o Polimorfismo resolve

Imagine que você esteja trabalhando em um e-commerce que disponibiliza como formas de pagamentos boleto bancário e cartão de crédito. Nesse caso o código associado ao checkout pode ser algo como:

class BilletTransaction {
  processBillet(): void {
    console.log("Processing billet...");
  }
}

class CreditCardTransaction {
  processCreditCard(): void {
    console.log("Processing credit card...");
  }
}

function processTransaction(transaction: BilletTransaction | CreditCardTransaction) {
  if (transaction instanceof BilletTransaction) {
    transaction.processBillet();
  } else if (transaction instanceof CreditCardTransaction) {
    transaction.processCreditCard();
  }
}

No código acima temos uma função processTransaction que é responsável por processar as duas formas de pagamentos, boleto (billet) e o cartão de crédito (credit card). Repare que tem um if para cada forma de pagamento.

O processamento será realizado de acordo com o tipo de estrutura de dados que será fornecido via parâmetro transaction que pode ser BilletTransaction ou CreditCardTransaction.

Agora imagine que esse e-commerce vai passar a disponibilizar uma nova forma de pagamento, o Pix. Nesse caso esse código deverá ser atualizado em dois pontos:

Pode parecer algo pequeno, mas imagine em um sistema onde essas formas de pagamento escalam rapidamente. Vamos ficar adicionando if e mais if? O exemplo acima é sobre formas de pagamento mas pode ser usado em diferentes casos de uso onde poderemos ter uma infinidade de opções a serem tratadas.

O trecho de código acima representa uma solução frágil e o Polimorfismo resolve isso de forma limpa, simples e elegante.

Polimorfismo na prática

Diante do problema apresentado, podemos melhorar a solução aplicando o Polimorfismo. Vejamos o código abaixo:

enum TransactionStatus {
  Pending = "pending",
  Processed = "processed",
}

class Transaction {
  constructor(
    public readonly id: string,
    public amount: number,
    protected status: TransactionStatus = TransactionStatus.Pending
  ) {}

  // Método que TODAS as filhas devem implementar do seu jeito
  public process(): void {
    throw new Error("process() must be implemented by subclass.");
  }

  public getReceipt(): string {
    return `Transaction ${this.id} — Amount: $${this.amount} — Status: ${this.status}`;
  }
}

class BilletTransaction extends Transaction {
  constructor(id: string, amount: number, public name: string) {
    super(id, amount);
  }

  public process(): void {
    this.status = TransactionStatus.Processed;
    console.log(`Billet generated to ${this.name}`);
  }
}

class CreditCardTransaction extends Transaction {
  constructor(id: string, amount: number, public cardLastDigits: string, public installments: number) {
    super(id, amount);
  }

  public process(): void {
    this.status = TransactionStatus.Processed;
    console.log(`Charged card **** ${this.cardLastDigits} in ${this.installments}x`);
  }
}

function processTransaction(transaction: Transaction) {
  transaction.process();
  console.log(transaction.getReceipt());
}

const billet = new BilletTransaction("1", 100, "John Doe");
const creditCard = new CreditCardTransaction("2", 200, "1234", 3);

processTransaction(billet);
processTransaction(creditCard);

// Output:
// Billet generated to John Doe
// Transaction 1 — Amount: $100 — Status: processed
// Charged card **** 1234 in 3x
// Transaction 2 — Amount: $200 — Status: processed

Explicando rapidamente, temos uma classe Transaction que é a classe pai onde temos seus atributos (características) e métodos (comportamentos). Repare que nessa classe temos um método process declarado e não implementado. Guarda ele ai que você já vai entender o motivo.

Em seguida temos a classe BilletTransaction que estende a classe pai Transaction, de onde ela herda seus atributos e comportamentos e adiciona seus atributos e comportamentos. Agora veja que nessa classe existe um método process implementado que sobrescreve o método process da classe pai.

O mesmo acontece com a classe CreditCardTransaction, estende a classe pai, adiciona seus atributos e métodos e tem um método process que sobrescreve o método existente na classe pai.

Agora que vem a solução do problema! Temos uma mesma função chamada processTransaction que recebe um único tipo Transaction e simplesmente executa um único método process. Devido à aplicação do conceito de Polimorfismo, não é mais necessário prever os tipos que podem ser recebidos como parâmetro da função e tão pouco ter um if para cada tipo.

Basta simplesmente instanciar a classe com a forma de pagamento que foi selecionada (exemplo const billet = new BilletTransaction("1", 100, "John Doe");) e chamar a função (processTransaction(billet);). O mesmo ocorre com transações com cartão de crédito.

Quando for necessário adicionar uma forma de pagamento Pix, por exemplo, basta criar a classe que faz o processamento dessa forma de pagamento estendendo a classe pai Transaction e usar. Top né?

Tá, mas você comentou que tem duas formas de utilizar o Polimorfismo, quais são? Veremos agora!

Polimorfismo com herança

A primeira forma de utilização do polimorfismo é através de herança – por isso que comentei no início do artigo que a leitura dos artigos anteriores da série seria muito importante para o entendimento do polimorfismo – onde o comportamento é alterado de acordo com a necessidade.

Foi exatamente o exemplo explicado acima. Tem uma classe filha que herda características da classe pai. No entanto a classe pai é centralizadora, ou seja, ela que orquestra tudo dizendo que todas as filhas devem ter atributos em comum e métodos em comum, que nesse caso é o método process. Repare que a função processTransaction recebe um tipo transaction, porém quando acionamos essa função passamos um tipo que é da classe filha que estende a classe pai.

O código acima pode e deve ser melhorado pelo seguinte aspecto: o ideal é que a classe filha seja obrigada a implementar os atributos e determinados métodos da classe pai – digo determinados pois vai depender do caso de uso, pode ser um ou vários métodos.

Repare que o método process da classe pai lança um erro caso a classe filha não implemente um método process. Isso é frágil, pois se esquecermos de lançar esse erro, o TypeScript não vai avisar sobre esse esquecimento.

O ideal é converter a classe pai em uma classe abstrata. Ficaria assim:

enum TransactionStatus {
  Pending = "pending",
  Processed = "processed",
}

abstract class Transaction {
  constructor(
    public readonly id: string,
    public amount: number,
    protected status: TransactionStatus = TransactionStatus.Pending
  ) {}

  // Método que TODAS as filhas devem implementar do seu jeito
  abstract process(): void;

  public getReceipt(): string {
    return `Transaction ${this.id} — Amount: $${this.amount} — Status: ${this.status}`;
  }
}

class BilletTransaction extends Transaction {
  constructor(id: string, amount: number, public name: string) {
    super(id, amount);
  }

  public process(): void {
    this.status = TransactionStatus.Processed;
    console.log(`Billet generated to ${this.name}`);
  }
}

class CreditCardTransaction extends Transaction {
  constructor(id: string, amount: number, public cardLastDigits: string, public installments: number) {
    super(id, amount);
  }

  public process(): void {
    this.status = TransactionStatus.Processed;
    console.log(`Charged card **** ${this.cardLastDigits} in ${this.installments}x`);
  }
}

function processTransaction(transaction: Transaction) {
  transaction.process();
  console.log(transaction.getReceipt());
}

const billet = new BilletTransaction("1", 100, "John Doe");
const creditCard = new CreditCardTransaction("2", 200, "1234", 3);

processTransaction(billet);
processTransaction(creditCard);

// Output:
// Billet generated to John Doe
// Transaction 1 — Amount: $100 — Status: processed
// Charged card **** 1234 in 3x
// Transaction 2 — Amount: $200 — Status: processed

Repare que foram duas únicas modificações e todas na classe pai:

Com essas duas modificações se as classes filhas não implementarem o método process o TypeScript vai lançar um erro dizendo que é obrigatório implementar o método process. Ai ficou chique!

Polimorfismo com interface

Essa outra forma é muito usada, por exemplo, em aplicações construídas empregando conceitos de Clean Architecture, onde temos interfaces entre as camadas da aplicação.

As interfaces funcionam como contratos entre as camadas no qual são definidos comportamentos esperados entre as camadas. Vamos a um exemplo:

interface Processable {
  process(): void;
  getReceipt(): string;
}

class BilletTransaction implements Processable {
  constructor(public id: string, public barCode: string) {}

  process(): void {
    console.log(`Billet ${this.barCode} registered.`);
  }

  getReceipt(): string {
    return `Billet ${this.id} — Bar code: ${this.barCode}`;
  }
}

class CreditCardTransaction implements Processable {
  constructor(
    public id: string,
    public cardLastDigits: string,
    public installments: number
  ) {}

  process(): void {
    console.log(`Charged card **** ${this.cardLastDigits} in ${this.installments}x`);
  }

  getReceipt(): string {
    return `Credit card ${this.id} — Card: **** ${this.cardLastDigits} — ${this.installments}x`;
  }
}

function processTransaction(transaction: Processable) {
  transaction.process();
  console.log(transaction.getReceipt());
}

const billet = new BilletTransaction("1", "12345.67890");
const creditCard = new CreditCardTransaction("2", "1234", 3);

processTransaction(billet);
processTransaction(creditCard);

// Output:
// Billet 12345.67890 registered.
// Billet 1 — Bar code: 12345.67890
// Charged card **** 1234 in 3x
// Credit card 2 — Card: **** 1234 — 3x

Note que as classes BilletTransaction e CreditCardTransaction implementam a interface Processable, diferentemente do Polimorfismo por herança onde a classe filha estende a classe pai. Dessa forma, todos os métodos (comportamentos) especificados na interface devem existir na classe que “assina”, “implementa” esse “contrato”. Caso a classe não contenha algum método especificado na interface, o TypeScript vai lançar um erro.

Herança ou interface, quando usar cada um?

Use a classe abstrata quando as classes compartilham atributos (características) e métodos (comportamentos) em comum — como idamount e process() no nosso exemplo. Use a interface quando quer apenas definir um contrato de comportamento puro, sem nenhuma implementação compartilhada. Em aplicações com Clean Architecture, as interfaces são a forma mais comum de garantir o baixo acoplamento entre as camadas.

Vantagens de utilizar Polimorfismo

Vejamos algumas vantagens de utilizar o polimorfismo:

Conclusão

O Polimorfismo é um pilar fundamental da POO que, quando bem aplicado, resulta em um código mais flexível, extensível e de fácil manutenção. A capacidade de tratar objetos de tipos diferentes através de uma interface comum elimina condicionais desnecessários e abre o código para extensão sem precisar modificar o que já funciona.

Uma observação importante é que o Polimorfismo não existe de forma isolada. Ele caminha de mãos dadas com os pilares anteriores: o Encapsulamento protege o estado interno dos objetos, a Herança permite o reaproveitamento de comportamentos, e o Polimorfismo permite que esses comportamentos sejam substituídos de forma transparente. São conceitos complementares que juntos formam a base de uma boa arquitetura orientada a objetos.

Espero que esse artigo tenha contribuído para seu aprendizado ou entendimento sobre o conceito de Polimorfismo, um dos 4 pilares da Programação Orientada a Objetos. No próximo artigo veremos o quarto e último pilar, a Abstração.

Forte abraço e até mais!