União e intersecção de tipos: como funciona?

Photo by Deva Darshan from Pexels

Ao aprender programação, uma das primeiras coisas com as quais nos deparamos são os tipos de dados. No mundo real, toda informação possui um tipo – texto, número, imagem, lista – e é por isso que passamos esse mesmo conceito para a programação. Contudo, apesar do conceito comum, cada linguagem tem uma abordagem diferente quanto à tipagem. Linguagens como Java, C e Pascal exigem que toda variável seja declarada com um tipo enquanto em JavaScript e Python o tipo da variável pode mudar ao longo da execução.

//Exemplo em Java
String dados = "Bluend"; 
dados = 123; //ops! erro de compilação, variável do tipo String não pode ser número

//Exemplo em JavaScript
var dados = "Bluend"
dados = 123 //tudo ok!

Bom, até aí nada demais. Cada variável tem um tipo, seja ele estático ou dinâmico, e os retornos de métodos/funções tem, de mesma forma, um tipo único. Mas será que poderia ser útil ter a possibilidade de múltiplos tipos em um retorno? Embora isso não seja possível no Java, a documentação do TypeScript apresenta alguns exemplos interessantes de tipagem combinada no artigo Advanced Types. De forma a explicar melhor, vou começar comparando um exemplo de código Java com uma possível contraparte em TypeScript.

Imagine que por algum motivo queremos representar Animais de forma geral e também certas especializações dessa classe (como Cachorro e Gato, por exemplo) em Java.

 public class Animal {
     public void comer() …
     public void beber() …
 }

 public class Cachorro extends Animal {
     public void correr() …
     public void latir() …
 }
 public class Gato extends Animal {
     public void correr() …
     public void miar() …
 }

Caso quiséssemos criar um método que retorna tanto Cachorros quanto Gatos, teríamos um problema: só pode haver 1 tipo de retorno de cada vez. Como resolver esse problema então? Retornando a classe pai, é claro! Se tanto Cachorros quanto Gatos são Animais, basta retornar Animais. Só tem um problema: caso queiramos utilizar métodos específicos de uma classe filha, seremos obrigados pelo Java a realizar casting, ou seja, uma conversão de tipos.

 public Animal getAnimalAleatorio() {
    //seleciona um animal aleatório de uma lista
    Animal aleatorio = ...
    return aleatorio;
 }

 Animal a = getAnimalAleatorio();
 a.latir() //erro: Cachorro pode latir, mas nem todo Animal
 (Cachorro) a.latir() //após o cast para Cachorro, agora é possível chamar o método latir

Apesar de “entender” que um Cachorro é um Animal, o compilador não verifica o inverso, e portanto não sabe dizer se o método “latir” é acessível através daquela instância de animal. Aliás, no exemplo acima o casting ocorreu sem erros, mas isso não necessariamente ocorreria na prática. E se tentássemos converter para o tipo Cachorro um Animal que é do tipo Gato? É claro que não funcionaria. O TypeScript resolve isso utilizando-se do conceito de união de conjuntos (sim, o mesmo que é utilizado na matemática). Sendo assim, basta criar uma função que possa retornar Cachorro OU Gato.

function getAnimalAleatorio(): Cachorro | Gato {     // ... }

let a = getAnimalAleatorio()
a.latir() //se a função existe na classe irá executar, senão retorna erro

Da mesma forma, é possível criar intersecções de tipos: variáveis que são de um tipo E também de outro, simultaneamente. Na prática isso não pode acontecer, afinal, não pode haver um Cachorro que também é Gato, ou uma String que também é Number. Embora o TypeScript mostre poucos exemplos simplificados desse conceito, encontrei na documentação de um verificador de tipos para JavaScript, o Flow, uma explicação que vai direto ao ponto:

Utilizando intersecção de tipos, é possível criar tipos impossíveis em tempo de execução. Isso lhe permitirá combinar quaisquer conjuntos de tipos, mesmo que sejam conflitantes. (…) Não há uso prático para a criação desses tipos, mas esse é um efeito colateral do funcionamento dos tipos de intersecção.

Contudo, as intersecções não são completamente inúteis. O TypeScript permite que utilizemos uma intersecção para assegurar que uma função só receberá parâmetros de um determinado conjunto de tipos. Geralmente, fazemos isso informando múltiplos parâmetros na assinatura da função, mas aplicando esse conceito precisaremos de apenas 1 parâmetro:

//para começar, criamos apelidos para tipos já existentes
type A = { idade: number }; 
type B = { sabeProgramar : boolean }; 
type C = { nome: string }; 

//depois, é só passá-los na função
function setDadosPessoa(dados: A & B & C) {   // ... }

setDadosPessoa({ idade: 25, sabeProgramar: true, nome: 'Dany' });

Considerações finais: os conceitos de união e intersecção de tipos podem ser bem úteis em casos específicos, mas são potencialmente confusos. Se você está recém começando a programar, não se preocupe muito com eles. E se for usar, use com moderação.

Fontes: