skip to content

Programação Orientada a Objetos em TypeScript

13 min read

Um guia completo sobre Programação Orientada a Objetos em TypeScript: dos mecanismos básicos aos quatro pilares fundamentais: Herança, Polimorfismo, Encapsulamento e Abstração.

Como ler este artigo?

Este artigo está dividido em duas partes. A primeira aborda os mecanismos da linguagem TypeScript que permitem a implementação da Programação Orientada a Objetos (POO). Já a segunda parte trata dos conceitos derivados do uso desses mecanismos, que dão origem aos quatro pilares da POO — herança, polimorfismo, encapsulamento e abstração.

Enquanto muitos professores, livros e cursos começam explicando esses pilares, eu prefiro iniciar pelos mecanismos da linguagem. O motivo é simples: eles são estruturas formais, ou seja, concretas. Além disso, ao longo do artigo, você perceberá que os pilares da POO são uma consequência do uso adequado das estruturas da linguagem.

Antes de começar, uma última observação:

Este artigo contém muitos exemplos de código, e cada um deles vem acompanhado de comentários explicativos para ajudá-lo a compreender o que está sendo programado. Não pule os códigos, muito menos os comentários! Eles fazem parte essencial da explicação.


Mecanismos da Linguagem

Nesta seção, exploraremos os recursos do TypeScript que possibilitam a implementação da POO. Esses mecanismos também estão presentes em outras linguagens orientadas a objetos, como Java e C#, podendo apresentar variações na sintaxe, mas preservando a ideia por trás dos mesmos.

Objeto

Em um contexto isolado, um objeto pode ser definido como um tipo de dado usado para armazenar uma coleção de valores, que podem ser dados primitivos ou outros objetos, organizados em pares chave/valor (key/value). No exemplo a seguir, o objeto pessoa armazena diversas informações, como a chave nome, que contém o valor “Lucas” do tipo “string” e a chave endereco que armazena outro objeto.

const pessoa = {
  nome: "Lucas", //valor primitivo do tipo string
  sobrenome: "Garcez",
  idade: 28, // valor primitivo do tipo number
  endereco: {
    // tipo objeto e contém as chaves "cidade" e "pais"
    cidade: "Melbourne",
    pais: "Australia",
  },
};

Classes, Atributos e Métodos

Uma classe é um template para criação de objetos. Ela define a estrutura e o comportamento de um objeto através de atributos e métodos. Os atributos definem a estrutura dos dados (chaves e tipos dos valores), e os métodos são ações que podem ser realizadas nos atributos.

class Pessoa {
  nome: string; // atributo
  sobrenome: string; // atributo
  idade: number; // atributo
 
  // método construtor (especial)
  constructor(nome: string, sobrenome: string, idade: number) {
    this.nome = nome;
    this.sobrenome = sobrenome;
    this.idade = idade;
  }
 
  // método para obter o nome completo: "Lucas Garcez"
  obterNomeCompleto() {
    return `${this.nome} ${this.sobrenome}`;
  }
}

Método construtor (constructor)

O construtor é uma método especial dentro de uma classe que é automaticamente chamado quando um novo objeto é criado. Ele é responsável por inicializar os atributos da classe com valores fornecidos no momento de criar um objeto utilizando a classe. Em TypeScript o construtor é definido através a palavra-chave constructor como apresentado no código acima.

Instância

Uma instância é como chamamos um objeto criado a partir de uma classe. Por exemplo, dada a classe Pessoa acima, podemos criar um objeto chamado lucas. Dessa forma, lucas é uma instância da classe Pessoa. Para criar uma instância de um objeto em JavaScript/TypeScript, utilizamos a palavra-chave new, como no exemplo abaixo:

const lucas = new Pessoa("Lucas", "Garcez", 28);
lucas.nome; // "Lucas"
lucas.obterNomeCompleto(); // "Lucas Garcez"

É importante destacar que podemos criar inúmeros objetos (instâncias) a partir de uma mesma classe e que, embora todos esses objetos compartilhem a mesma estrutura (atributos e métodos), eles são independentes e ocupam espaços distintos na memória do programa. Por exemplo, ao instanciar um novo objeto:

const maria = new Pessoa("Maria", "Oliveira", 19);

Temos uma nova instância da classe Pessoa, que não interfere no objeto lucas criado anteriormente. Cada instância mantém seus próprios valores e comportamentos, garantindo que a manipulação de um objeto não afete os demais.

Interface

Uma interface define um contrato que estabelece quais atributos e métodos uma classe deve obrigatoriamente implementar. Em TypeScript, essa relação é estabelecida por meio da palavra-chave implements. Quando uma classe implementa uma interface, necessariamente deve contar todos os atributos e métodos especificados na interface, bem como os seus respectivos tipos.

No exemplo a seguir, temos um sistema bancário onde um cliente pode ter uma ContaCorrente ou uma ContaPoupanca. Ambas devem seguir as regras gerais do banco para contas, que são definidas na interface ContaBancaria.

// Contrato que define os atributos e métodos de uma conta bancária
interface ContaBancaria {
  saldo: number;
  depositar(valor: number): void;
  sacar(valor: number): void;
}
 
class ContaCorrente implements ContaBancaria {
  saldo: number;
  // A classe pode contar outros atributos e métodos
  // além dos especificado na interface
  limiteChequeEspecial: number;
 
  depositar(valor: number): void {
    this.saldo += valor;
  }
 
  sacar(valor: number): void {
    if (valor <= this.saldo) {
      this.saldo -= valor;
    }
  }
}
 
class ContaPoupanca implements ContaBancaria {
  saldo: number;
 
  depositar(valor: number): void {
    // pode contar uma lógica diferente da ContaCorrente
    // só precisa respeitar a assinatura do método,
    // ou seja, os parâmetros (valor: number) e o tipo do retorno (void)
  }
 
  sacar(valor: number): void {
    // ...
  }
}

Classes Abstratas

Assim como as interfaces, as classes abstratas são utilizadas para definir um modelo ou contrato que outras classes devem seguir. No entanto, enquanto uma interface apenas descreve a estrutura de uma classe sem fornecer implementações, uma classe abstrata pode conter tanto declarações de métodos quanto implementações concretas. Porém, diferente das classes comuns, uma classe abstrata não pode ser instanciada diretamente e serve apenas como base para que outras classes herdem seus métodos e atributos.

Em TypeScript, a palavra-chave abstract é usada para definir uma classe abstrata. A seguir, vamos refatorar o exemplo do sistema bancário, substituindo a interface por uma classe abstrata para definir um comportamento base para todas as contas bancárias.

// Classe abstrata que serve como base para qualquer tipo de conta bancária
abstract class ContaBancaria {
  saldo: number;
 
  constructor(saldoInicial: number) {
    this.saldo = saldoInicial;
  }
 
  // Método concreto (com implementação)
  depositar(valor: number): void {
    this.saldo += valor;
  }
 
  // Método abstrato (deve ser implementado pelas subclasses)
  abstract sacar(valor: number): void;
}
 
class ContaCorrente extends ContaBancaria {
  sacar(valor: number): void {
    const taxa = 2; // Conta corrente tem taxa fixa de saque
    const valorTotal = valor + taxa;
 
    if (this.saldo >= valorTotal) {
      this.saldo -= valorTotal;
    } else {
      console.log("Saldo insuficiente.");
    }
  }
}
 
class ContaPoupanca extends ContaBancaria {
  sacar(valor: number): void {
    if (this.saldo >= valor) {
      this.saldo -= valor;
    } else {
      console.log("Saldo insuficiente.");
    }
  }
}
 
// ❌ Erro! Não é possível instanciar uma classe abstrata
const contaBancaria = new ContaBancaria(1000);
 
// ✅ Criando uma conta corrente
const contaCorrente = new ContaCorrente(2000); // utiliza o método construtor da ContaBancaria
contaCorrente.depositar(500); // utiliza o método depositar da ContaBancaria
contaCorrente.sacar(300); // utiliza o método sacar da ContaCorrente
 
// ✅ Criando uma conta poupança
const contaPoupanca = new ContaPoupanca(1500); // utiliza o método construtor da ContaBancaria
contaPoupanca.depositar(1100); // utiliza o método depositar da ContaBancaria
contaPoupanca.sacar(500); // utiliza o método sacar da ContaPoupanca

Os Pilares da Orientação a Objeto

Agora que compreendemos boa parte dos mecanismos da linguagem, podemos formalizar os pilares da Programação Orientada a Objetos que guiam a construção de sistemas mais organizados, reutilizáveis e escaláveis.

Herança - superclass e subclass

A herança é um mecanismo que permite que uma classe derive características de outra. Quando uma classe A herda de uma classe B, isso significa que A automaticamente possui os atributos e métodos de B, sem precisar redefini-los.

Podemos imaginar essa relação como uma estrutura de pai e filho, onde B é a superclasse (classe base/pai) e A é a subclasse (classe derivada/filho). A subclasse pode tanto utilizar os recursos herdados quanto adicionar novos comportamentos ou sobrescrever métodos da superclasse para atender necessidades específicas.

Já abordamos o conceito de herança ao explicar as classes abstratas, mas a herança também pode ser aplicada a classes concretas, permitindo o reuso de código e a especialização de comportamentos.

// ContaBancaria agora é uma classe comum onde definimos atributos e métodos
// que serão reutilizados pelas classe filha ContaCorrente
class ContaBancaria {
  saldo: number = 0;
 
  constructor(saldoInicial: number) {
    this.saldo = saldoInicial;
  }
 
  depositar(valor: number): void {
    this.saldo += valor;
  }
 
  sacar(valor: number): void {
    if (valor <= this.saldo) {
      this.saldo -= valor;
    }
  }
}
 
// ContaCorrente é uma subclasse de ContaBancaria, ou seja, herda seus atributos e métodos.
// A ContaCorrente estabelece um novo atributo chamado limiteChequeEspecial
// e também sobrescreve o método "sacar" para definir uma regra diferente de saque.
class ContaCorrente extends ContaBancaria {
  limiteChequeEspecial: number; // novo atributo exclusivo da ContaCorrente
 
  // Quando especificamos um método construtor de uma subclasse,
  // precisamos chamar outro um método especial, o "super".
  // Esse método chama o construtor da classe pai (ContaBancaria) para que ela
  // seja inicializada antes mesmo da criação do objeto ContaCorrente em si.
  constructor(saldoInicial: number, limiteChequeEspecial: number) {
    super(saldoInicial); // Deve respeitar a assinatura do método construtor da classe pai
    this.limiteChequeEspecial = limiteChequeEspecial;
  }
 
  // Apesar do método sacar já estar presente na superclasse (ContaBancaria),
  // ele está sendo sobrescrito aqui. Na prática, isso significa que
  // toda vez que um objeto criado com ContaCorrente chamar o método sacar,
  // esta implementação será utilizada e a da classe pai será ignorada.
  override sacar(valor: number): void {
    const limiteTotal = this.saldo + this.limiteChequeEspecial;
    if (valor > 0 && valor <= limiteTotal) {
      this.saldo -= valor;
    }
  }
}
 
// Criando uma conta corrente com saldo inicial de R$0,00
// e um limite de R$100 no cheque especial.
const contaCorrente = new ContaCorrente(0, 100);
 
// Realizando um depósito de R$200 chamando o método depositar
// Nesse caso, o método chamado será o da ContaBancaria, tendo
// em vista que depositar não foi sobrescrito na ContaCorrente
contaCorrente.depositar(200); // saldo 200
 
// Realizando um saque de R$250 chamando o método sacar
// Nesse caso, o método chamado será o da ContaCorrente
// pois ele foi sobrescrito ao defini-la
contaCorrente.sacar(250); // saldo -50

Polimorfismo

O polimorfismo é um conceito que frequentemente gera dúvidas na Programação Orientada a Objetos, mas, na prática, ele é apenas uma consequência natural do uso de interfaces e herança.

O termo polimorfismo vem do grego e significa “muitas formas” (poli = muitas, morphos = formas). Esse conceito permite que objetos de diferentes classes respondam à mesma chamada de método, mas com implementações distintas, tornando o código mais flexível e reutilizável.

Para esclarecer esse conceito, vejamos um exemplo prático. Suponha que temos uma função enviarValor, responsável por processar uma transação financeira, transferindo uma determinada quantia de uma conta A para uma conta B. A única exigência é que ambas as contas sigam um contrato comum, garantindo que os métodos sacar e depositar estejam disponíveis.

// ContaBancaria poderia ser uma interface, quanto uma, classe concreta
// ou uma classe abstrata. Para a função enviarValor não importa a implementação
// apenas que a ContaBancaria tenha os métodos sacar e depositar
function enviarValor(
  enviador: ContaBancaria,
  recebedor: ContaBancaria,
  valor: number
) {
  enviador.sacar(valor);
  recebedor.depositar(valor);
}
 
const contaLucas = new ContaCorrente();
const contaMaria = new ContaPoupanca();
 
// enviando $100 do Lucas para a maria;
enviar(lucas, maria, 100);

Métodos Polimórficos: sacar e depositar são chamados na função enviarValor, sem que essa função precise saber se está lidando com uma conta corrente ou poupança. Cada classe implementa sacar de acordo com suas regras próprias, demonstrando o conceito de polimorfismo.

Desacoplamento: A função enviarValor não depende do tipo específico da conta bancária. Qualquer classe que estenda ContaBancaria (caso seja uma classe) ou implemente ContaBancaria (caso seja uma interface), pode ser usada sem que precisemos modificar o código da função enviarValor.

Com essa abordagem, garantimos flexibilidade e reaproveitamento de código, pois novas classes de conta podem ser adicionadas sem impactar o funcionamento da função enviarValor.

Encapsulamento

O encapsulamento é um dos princípios fundamentais da POO, mas seu conceito pode ser aplicado em qualquer paradigma de programação. Ele consiste em ocultar os detalhes internos de implementação de um módulo, classe, função ou qualquer outro componente de software, expondo apenas o necessário para seu uso externo. Isso melhora a segurança, manutenibilidade e modularidade do código, evitando acessos indevidos e garantindo que as interações ocorram de maneira controlada.

Modificadores de acesso - public, private e protected

No contexto da POO, o encapsulamento é essencial para controlar a visibilidade e o acesso a métodos e atributos dentro de uma classe. Em TypeScript, isso é feito por meio dos modificadores de acesso, que são expressos através das palavras reservadas public, protected e private.

  • public - Permite que o atributo ou método seja acessado de qualquer lugar, dentro ou fora da classe. Essa é a visibilidade padrão, ou seja, caso o modificador de acesso não seja especificado no código, o TypeScript o tomará como public.

  • protected - Permite o acesso dentro da classe e também em suas subclasses, mas impede o acesso externo.

  • private - Restringe o acesso ao atributo ou método apenas à própria classe.

export class Pessoa {
  private nome: string; // Apenas acessível dentro da própria classe
  private sobrenome: string; // Apenas acessível dentro da própria classe
  protected dataNascimento: Date; // Pode ser acessado por subclasses, mas não fora delas
 
  constructor(nome: string, sobrenome: string, dataNascimento: Date) {
    this.nome = nome;
    this.sobrenome = sobrenome;
    this.dataNascimento = dataNascimento;
  }
 
  // Método público que pode ser acessado de qualquer lugar
  public getNomeCompleto(): string {
    return `${this.nome} ${this.sobrenome}`;
  }
}
 
// A classe Professor herda de Pessoa e, portanto, pode acessar
// atributos e métodos conforme os modificadores de acesso permitirem.
class Professor extends Pessoa {
  constructor(nome: string, sobrenome: string, dataNascimento: Date) {
    super(nome, sobrenome, dataNascimento); // Chama o construtor da superclasse (Pessoa)
  }
 
  getPerfil() {
    this.dataNascimento; // ✅ Pode ser acessado pois está como protected
    this.getNomeCompleto(); // ✅ Pode ser acessado pois é público
    this.nome; // ❌ Erro! Não pode ser acessado pois é private na classe Pessoa
    this.sobrenome; // ❌ Erro! Não pode ser acessado pois é private na classe Pessoa
  }
}
 
function main() {
  // Criando uma instância de Professor
  const lucas = new Professor("Lucas", "Garcez", new Date("1996-02-06"));
 
  // Testando acessos diretos aos atributos e métodos
  lucas.dataNascimento; // ❌ Erro! dataNascimento é protected e só pode ser acessado dentro da classe ou subclasses
  lucas.getNomeCompleto(); // ✅ Pode ser acessado, pois é um método público
  lucas.nome; // ❌ Erro! nome é privado e não pode ser acessado fora da classe Pessoa
  lucas.sobrenome; // ❌ Erro! sobrenome também é privado e inacessível fora da classe Pessoa
}
ModificadoresAcesso na classeAcesso na subclasseAcesso fora da classe
public✅ Sim✅ Sim✅ Sim
protected✅ Sim✅ Sim❌ Não
private✅ Sim❌ Não❌ Não

Abstração

O conceito de abstração muitas vezes gera dúvidas porque seu significado vai além do contexto técnico. Se buscarmos a definição da palavra em inglês, o Cambridge Dictionary define “abstract” como:

Algo que existe como uma ideia, sentimento ou qualidade, e não como um objeto material.

Essa definição pode ser aplicada diretamente à POO: abstração é a representação de uma ideia ou conceito, sem entrar em detalhes concretos de implementação.

Muitas referências na internet apresentam abstração como a ideia de “esconder detalhes de implementação”, o que pode gerar confusão, pois esse conceito está mais relacionado ao encapsulamento. Na POO, a abstração NÃO significa esconder detalhes, mas sim definir contratos por meio de classes abstratas e interfaces. Aqui não cabe apresentar exemplos em código, pois basta voltar algumas seções acima e olhar os exemplos de classes abstratas e interfaces – isso é abstração!

Conclusão

Se você deseja aprender mais sobre Programação Orientada a Objetos e se aprofundar em TypeScript, não deixe de conferir o meu treinamento onde exploramos exemplos práticos e aprofundados, abordando conceitos avançados para que você passe a escrever código mais estruturado, reutilizável e profissional.