Fundamentos de Programação Orientada a Objetos em TypeScript

A programação orientada a objetos (POO) é um pilar essencial no desenvolvimento de software, frequentemente vista como um conceito complexo, mas que, na realidade, simplifica a estruturação de sistemas. Em essência, POO preconiza que todas as operações e interações dentro de um programa devem ser realizadas através de entidades denominadas objetos.

  • Para manipular um navegador web, interagimos com o objeto window.
  • Para modificar o conteúdo de uma página, utilizamos o objeto document.
  • Para exibir mensagens no console, recorremos ao objeto console.

Essa abordagem, onde tudo se baseia em objetos, deriva da natureza da computação: programas são abstrações da realidade. Uma fotografia é uma abstração de uma pessoa real; um modelo em miniatura é uma abstração de um veículo. No software, podemos representar diversas entidades do mundo real, como um indivíduo, um animal ou um item específico. Cada uma dessas representações se materializa como um objeto no contexto do programa.

Objetos são tipicamente compostos por duas partes: dados e funcionalidades. Tomemos um indivíduo como exemplo: seu nome, gênero, idade e altura são dados; enquanto falar, andar, comer e dormir são funcionalidades. No âmbito da POO, os dados são referidos como atributos (ou propriedades) e as funcionalidades como métodos. Assim, em um ambiente orientado a objetos, podemos afirmar que "tudo é um objeto".

  1. Classes

Para interagir com objetos, primeiro precisamos criá-los. A criação de objetos é precedida pela definição de uma classe. Uma classe pode ser entendida como um projeto, um molde ou uma planta para a construção de objetos. Com base em uma classe, o programa pode instanciar objetos de um tipo específico. Por exemplo, uma classe Pessoa pode ser usada para criar objetos que representam pessoas, e uma classe Carro para criar objetos que representam carros.

Definindo uma Classe:


class NomeDaClasse {
   propriedadeA: TipoA;
   propriedadeB: TipoB;

   constructor(paramA: TipoA, paramB: TipoB) {
       this.propriedadeA = paramA;
       this.propriedadeB = paramB;
   }

   metodoExemplo(): void {
       // Lógica do método
   }
}
   

Exemplo Prático:


class Paciente {
   nome: string;
   registro: number;

   constructor(nomeDoPaciente: string, numeroDeRegistro: number) {
       this.nome = nomeDoPaciente;
       this.registro = numeroDeRegistro;
   }

   exibirInfo(): void {
       console.log(`Paciente: ${this.nome}, Registro: ${this.registro}`);
   }
}
   

Utilizando a Classe:


const novoPaciente = new Paciente('Maria Silva', 12345);
novoPaciente.exibirInfo(); // Saída: Paciente: Maria Silva, Registro: 12345
   
  1. Características da Programação Orientada a Objetos

Encapsulamento

O encapsulamento é uma das principais características da POO, que consiste em agrupar dados (atributos) e métodos que operam sobre esses dados em uma única unidade (o objeto). Seu propósito primário é proteger a integridade dos dados, controlando como eles podem ser acessados e modificados. Por padrão, em TypeScript, os atributos de um objeto podem ser alterados livremente. Para garantir a segurança e o controle dos dados, o TypeScript oferece modificadores de acesso.

Atributos Somente Leitura (readonly):

Ao declarar um atributo com a palavra-chave readonly, ele se torna imutável após a inicialização, seja no construtor ou diretamente na declaração.

Modificadores de Acesso em TypeScript:

  • public (padrão): Atributos e métodos são acessíveis em qualquer lugar – dentro da própria classe, em classes filhas (subclasses) e através de instâncias do objeto.
  • protected: Atributos e métodos são acessíveis dentro da própria classe e em suas subclasses. Não são acessíveis por instâncias externas do objeto.
  • private: Atributos e métodos são acessíveis apenas dentro da própria classe onde foram declarados. Nem subclasses nem instâncias externas podem acessá-los.

Exemplos de Modificadores de Acesso:

public:

class Funcionario {
   public nomeCompleto: string; // 'public' é o padrão, pode ser omitido
   public setor: string;

   constructor(nome: string, area: string) {
       this.nomeCompleto = nome; // Acessível e modificável dentro da classe
       this.setor = area;
   }

   saudar(): void {
       console.log(`Olá, sou ${this.nomeCompleto} do setor ${this.setor}.`);
   }
}

class Gerente extends Funcionario {
   constructor(nome: string, area: string) {
       super(nome, area);
       this.nomeCompleto = `Sr(a). ${nome}`; // Acessível e modificável em subclasses
   }
}

const colaborador = new Funcionario('João Neto', 'Vendas');
colaborador.nomeCompleto = 'João Silva'; // Acessível e modificável por uma instância do objeto
colaborador.saudar();
   
protected:

class EntidadeBase {
   protected identificador: string;
   protected dataCriacao: Date;

   constructor(id: string) {
       this.identificador = id; // Acessível dentro da classe
       this.dataCriacao = new Date();
   }

   obterID(): string {
       return this.identificador;
   }
}

class ItemEstoque extends EntidadeBase {
   nomeProduto: string;

   constructor(id: string, produto: string) {
       super(id);
       this.nomeProduto = produto;
       this.identificador = id.toUpperCase(); // Acessível e modificável em subclasses
   }
}

const item = new ItemEstoque('p-001', 'Caneta');
// item.identificador = 'novo-id'; // Erro: 'identificador' é protected e só pode ser acessado em subclasses
console.log(item.obterID()); // OK: Acesso via método público
   
private:

class UsuarioSistema {
   private senhaHash: string;
   private email: string;

   constructor(emailUsuario: string, senhaCriptografada: string) {
       this.email = emailUsuario; // Acessível dentro da classe
       this.senhaHash = senhaCriptografada;
   }

   autenticar(senhaFornecida: string): boolean {
       // Lógica de autenticação usando this.senhaHash
       return this.senhaHash === senhaFornecida;
   }

   alterarEmail(novoEmail: string): void {
       this.email = novoEmail;
   }
}

// class Administrador extends UsuarioSistema {
//     constructor(email: string, senha: string) {
//         super(email, senha);
//         // this.senhaHash = 'novaSenha'; // Erro: 'senhaHash' é private e não pode ser acessado em subclasses
//     }
// }

const user = new UsuarioSistema('admin@exemplo.com', 'hashSeguro123');
// user.senhaHash = 'senhaAberta'; // Erro: 'senhaHash' é private
console.log(user.autenticar('hashSeguro123')); // OK
   

Acessadores de Propriedades (Getters e Setters)

Quando um atributo é definido como private, ele não pode ser acessado ou modificado diretamente de fora da classe. Para permitir um acesso controlado, é comum definir métodos específicos para ler (getter) e escrever (setter) esses atributos. Esses métodos são conhecidos como acessadores de propriedade.


class Produto {
   private _codigoInterno: string; // Usamos '_' para indicar que é um atributo privado

   constructor(codigo: string) {
       this._codigoInterno = codigo;
   }

   // Getter para ler o código interno
   get codigo(): string {
       console.log("Acessando o código do produto...");
       return this._codigoInterno;
   }

   // Setter para modificar o código interno com validação
   set codigo(novoCodigo: string) {
       if (novoCodigo.length >= 5) {
           this._codigoInterno = novoCodigo;
           console.log("Código do produto atualizado.");
       } else {
           console.error("Erro: O código deve ter pelo menos 5 caracteres.");
       }
   }
}

const meuProduto = new Produto('P123');
console.log(meuProduto.codigo); // Usa o getter: "Acessando o código do produto..." e exibe "P123"
meuProduto.codigo = 'P45678'; // Usa o setter: "Código do produto atualizado."
meuProduto.codigo = 'P9'; // Usa o setter: "Erro: O código deve ter pelo menos 5 caracteres."
   

Atributos e Métodos Estáticos

Atributos e métodos estáticos pertencem à classe em si, e não a instâncias específicas dela. Eles são acessados diretamente pela classe, sem a necessidade de criar um objeto. São declarados com a palavra-chave static.


class UtilidadesMatematicas {
   static VALOR_PI: number = 3.14159; // Atributo estático

   static calcularAreaCirculo(raio: number): number { // Método estático
       return this.VALOR_PI * raio * raio;
   }
}

console.log(UtilidadesMatematicas.VALOR_PI); // Acesso direto ao atributo estático
console.log(UtilidadesMatematicas.calcularAreaCirculo(5)); // Acesso direto ao método estático
   

A Palavra-chave this

Em TypeScript, dentro do contexto de uma classe ou de seus métodos, this refere-se à instância atual do objeto. Ele é usado para acessar os atributos e chamar os métodos daquela instância específica.

Herança

A herança é um mecanismo que permite que uma nova classe (subclasse ou classe filha) adquira as propriedades e comportamentos (atributos e métodos) de uma classe existente (superclasse ou classe pai). Isso promove a reutilização de código e estabelece uma relação "é um tipo de" entre as classes.


class AnimalDomestico {
   nomeCientifico: string;
   expectativaVidaAnos: number;

   constructor(nomeCientifico: string, vidaAnos: number) {
       this.nomeCientifico = nomeCientifico;
       this.expectativaVidaAnos = vidaAnos;
   }

   emitirSom(): void {
       console.log('O animal faz um som genérico.');
   }
}

class Gato extends AnimalDomestico {
   raça: string;

   constructor(nomeCientifico: string, vidaAnos: number, tipoRaca: string) {
       super(nomeCientifico, vidaAnos); // Chama o construtor da classe pai
       this.raça = tipoRaca;
   }

   miar(): void {
       console.log(`O ${this.raça} está miando: Miau!`);
   }

   // Sobrescrevendo o método emitirSom da classe pai
   emitirSom(): void {
       console.log('O gato mia.');
   }
}

const felino = new Gato('Felis catus', 15, 'Siamês');
felino.miar(); // Saída: O Siamês está miando: Miau!
felino.emitirSom(); // Saída: O gato mia. (Método sobrescrito)
console.log(`Nome científico: ${felino.nomeCientifico}, Vida média: ${felino.expectativaVidaAnos} anos.`);
   

A herança possibilita estender funcionalidades de classes existentes sem modificá-las diretamente, garantindo a modularidade e a manutenibilidade do código. Em uma subclasse, a palavra-chave super é usada para referenciar a superclasse, permitindo chamar seu construtor ou seus métodos.

Classes Abstratas

Uma classe abstrata é uma classe que não pode ser instanciada diretamente. Ela serve como um modelo para outras classes que a herdarão. Classes abstratas podem conter métodos abstratos, que são declarados sem implementação e devem ser implementados por todas as subclasses concretas.


abstract class FormaGeometrica {
   // Método abstrato sem implementação. Deve ser implementado pelas subclasses.
   abstract calcularArea(): number;

   // Método concreto com implementação
   desenhar(): void {
       console.log('Desenhando uma forma geométrica...');
   }
}

class Retangulo extends FormaGeometrica {
   largura: number;
   altura: number;

   constructor(l: number, a: number) {
       super();
       this.largura = l;
       this.altura = a;
   }

   // Implementação do método abstrato calcularArea
   calcularArea(): number {
       return this.largura * this.altura;
   }
}

// const forma = new FormaGeometrica(); // Erro: Não é possível criar uma instância de uma classe abstrata
const meuRetangulo = new Retangulo(10, 5);
console.log(`Área do retângulo: ${meuRetangulo.calcularArea()}`); // Saída: Área do retângulo: 50
meuRetangulo.desenhar(); // Saída: Desenhando uma forma geométrica...
   
  1. Interfaces

Interfaces em TypeScript são contratos que definem a estrutura de um objeto. Elas especificam quais propriedades e métodos um objeto (ou uma classe) deve possuir. Ao contrário das classes abstratas, interfaces não contêm implementações; elas apenas declaram a "assinatura" do que se espera. Interfaces são usadas para verificar tipos de objetos ou para garantir que uma classe implemente um conjunto específico de funcionalidades.

Exemplo (Verificação de Tipo de Objeto):


interface EntidadeExibivel {
   id: string;
   exibirDetalhes(): void;
}

function processarExibivel(item: EntidadeExibivel): void {
   item.exibirDetalhes();
}

// Um objeto que corresponde à interface EntidadeExibivel
const meuUsuario = {
   id: 'user-456',
   nome: 'Carlos', // Propriedade extra é permitida, desde que as necessárias estejam lá
   exibirDetalhes() {
       console.log(`Detalhes do ID: ${this.id}, Nome: ${this.nome}`);
   }
};

processarExibivel(meuUsuario); // Saída: Detalhes do ID: user-456, Nome: Carlos

// Um objeto anônimo também pode ser compatível
processarExibivel({ id: 'prod-001', exibirDetalhes() { console.log(`Produto ID: ${this.id}`); } }); // Saída: Produto ID: prod-001
   

Exemplo (Implementação por Classe):


interface Registravel {
   registrarEvento(mensagem: string, nivel: 'info' | 'warn' | 'error'): void;
}

class ConsoleLogger implements Registravel {
   constructor(private nomeDoModulo: string) {}

   registrarEvento(mensagem: string, nivel: 'info' | 'warn' | 'error'): void {
       const timestamp = new Date().toISOString();
       console.log(`[${timestamp}] [${this.nomeDoModulo}] [${nivel.toUpperCase()}]: ${mensagem}`);
   }
}

const logSistema = new ConsoleLogger('SistemaPrincipal');
logSistema.registrarEvento('Aplicação iniciada com sucesso.', 'info');
logSistema.registrarEvento('Atenção: Disco quase cheio.', 'warn');
   
  1. Genéricos (Generics)

Genéricos em TypeScript são uma ferramenta poderosa que permite escrever componentes reutilizáveis que podem trabalhar com vários tipos de dados, sem comprometer a segurança de tipo. Eles resolvem o problema de ter que duplicar código ou usar o tipo any quando o tipo de um parâmetro, retorno ou propriedade não é conhecido no momento da definição da função ou classe.

O Problema com any:


function processarDadosInseguro(dado: any): any {
   return dado;
}
   

No exemplo acima, a função processarDadosInseguro aceita e retorna qualquer tipo. Embora funcione, o uso de any desativa as verificações de tipo do TypeScript, perdendo os benefícios de segurança de tipo e clareza, e não expressa a relação de que o tipo de retorno é o mesmo do parâmetro.

Utilizando Genéricos:


function identidade<T>(valor: T): T {
   return valor;
}
   

Aqui, <T> é o marcador de tipo genérico. T (um nome comum, mas pode ser qualquer identificador) representa um "tipo" que será definido quando a função for chamada. A função identidade agora garante que o tipo do argumento seja o mesmo que o tipo de retorno, mantendo a segurança de tipo.

Como Usar a Função Genérica:

  • Inferência de Tipo (Uso Direto):
    
    let resultadoNumero = identidade(123); // TypeScript infere T como number
    console.log(resultadoNumero); // 123
    let resultadoTexto = identidade("Olá Mundo"); // TypeScript infere T como string
    console.log(resultadoTexto); // Olá Mundo
    
    

    Na maioria dos casos, o TypeScript pode inferir o tipo genérico automaticamente. No entanto, em situações mais complexas, você pode precisar especificá-lo.

  • Especificação Explícita do Tipo:
    
    let meuNumero = identidade<number>(42); // Explicitamente definimos T como number
    let minhaString = identidade<string>("tipo específico"); // Explicitamente definimos T como string
    
    

Múltiplos Tipos Genéricos:

É possível definir vários parâmetros de tipo genérico, separados por vírgulas.


function mapear<Chave, Valor>(id: Chave, conteudo: Valor): { chave: Chave, dado: Valor } {
   return { chave: id, dado: conteudo };
}

const itemMapeado = mapear<string, number>("produtoID", 987);
console.log(itemMapeado.chave); // produtoID
console.log(itemMapeado.dado);  // 987
   

Genéricos em Classes:

Classes também podem ser genéricas, permitindo que suas propriedades ou métodos trabalhem com tipos defiindos no momento da instanciação da classe.


class Armazenamento<TipoDado> {
   private colecao: TipoDado[] = [];

   adicionar(item: TipoDado): void {
       this.colecao.push(item);
   }

   obterPrimeiro(): TipoDado | undefined {
       return this.colecao.length > 0 ? this.colecao[0] : undefined;
   }
}

const armazenaNumeros = new Armazenamento<number>();
armazenaNumeros.adicionar(100);
armazenaNumeros.adicionar(200);
console.log(armazenaNumeros.obterPrimeiro()); // 100

const armazenaStrings = new Armazenamento<string>();
armazenaStrings.adicionar("item A");
console.log(armazenaStrings.obterPrimeiro()); // item A
   

Restrições (Constraints) em Genéricos:

Para garantir que um tipo genérico possua certas propriedades ou métodos, podemos impor restrições usando a palavra-chave extends com uma interface ou classe. Isso permite acessar membros específicos do tipo genérico com segurança.


interface ComprimentoVerificavel {
   tamanho: number;
}

// A função só aceitará tipos que tenham a propriedade 'tamanho'
function logarTamanho<T extends ComprimentoVerificavel>(item: T): void {
   console.log(`O item tem um tamanho de: ${item.tamanho}`);
}

logarTamanho({ tamanho: 15, nome: "Objeto Teste" }); // Saída: O item tem um tamanho de: 15
// logarTamanho(10); // Erro: Argumento do tipo 'number' não é atribuível ao parâmetro do tipo 'ComprimentoVerificavel'.
   

Tags: TypeScript OOP classes Interfaces generics

Publicado em 6-15 06:14 por Thomas