TypeScript: Generics para pessoas que desistiram de entender Generics

Image for post
Image for post
Créditos da Imagem

Você já ficou confuso com o código a seguir? Se perguntando “Porquê TypeScript Generics é tão difícil?”

// Já ficou confuso com código como esse?
function makePair<
F extends number | string,
S extends boolean | F
>()

Se você é (1) novo no TypeScript, (2) novo com Generics e (3) lutando para entender as generics, então você é exatamente como eu quando eu estava aprendendo Java há 13 anos.

Como o TypeScript, a linguagem de programação Java suporta Generics. Quando eu estava estudando Java na faculdade, eu era um programador iniciante, e as Generics eram muito difíceis para mim. Então desisti de aprender na época e os usei sem saber o que estava fazendo. Eu não entendi Generics até conseguir um emprego em período integral depois da faculdade.

Talvez você seja como eu de 13 anos atrás e tenha sentido que as Generics em TypeScript são muito difíceis. Nesse caso, este tutorial é para você! Vou tentar ajudá-lo!

Vamos falar sobre makeState()

function makeState() {
let state: number
function getState() {
return state
}
function setState(x: number) {
state = x
}
return { getState, setState }
}

Quando você executa makeState(), ele retorna duas funções: getState() e setState(). Você pode usar essas funções para definir e obter a variável chamada state.

Vamos experimentar! O que é impresso no console quando você executa o código a seguir?

const { getState, setState } = makeState()
setState(1)
console.log(getState()) // 1
setState(2)
console.log(getState()) // 2

É mostrado 1 e depois 2. Muito simples, certo?

E se usarmos uma string?

const { getState, setState } = makeState()// O que acontece se usarmos uma string?
setState('foo')
console.log(getState())

O TypeScript não irá compilar, porque setState() espera um número:

function makeState() {
let state: number
function getState() {
return state
}
// Aqui o setState() espera um número
function setState(x: number) {
state = x
}
return { getState, setState }
}

Para consertar isso, podemos alterar o tipo de state e x de numberpara string:

function makeState() {
// Mudamos para string
let state: string
function getState() {
return state
}
// Aceita uma string
function setState(x: string) {
state = x
}
return { getState, setState }
}

Agora vai funcionar! 🥳

const { getState, setState } = makeState()
setState('foo')
console.log(getState()) // 'foo'

O nosso desafio é receber dois estados diferentes

Podemos modificar makeState() de modo que ele possa criar dois estados diferentes? Um que permita apenas números e o outro que permita apenas strings?

Aqui está o que eu quero dizer:

// Queremos modificar makeState() para suportar
// a criação de dois estados diferentes:
// Um que permita números...
const numState = makeState()
numState.setState(1)
console.log(numState.getState()) // 1
// E outro que permita strings.
const strState = makeState()
strState.setState('foo')
console.log(strState.getState()) // foo

No começo, nosso primeiro makeState() criou estados somente com números e nosso segundo makeState() criou estados somente com string. No entanto, não foi possível criar ambos os estados.

Como podemos modificar o makeState() para alcançar nosso objetivo?

Tentativa 1: Isso funciona?

function makeState() {
let state: number | string
function getState() {
return state
}
function setState(x: number | string) {
state = x
}
return { getState, setState }
}

Isso não funciona. Você acabará criando um estado que permite números e strings, que não é o que queremos. Ao invés disso, queremos que o makeState() suporte a criação de dois estados diferentes: um que permita apenas números e o outro que permita apenas strings.

// Não funciona porque o estado criado...
const numAndStrState = makeState()
// Permite números...
numAndStrState.setState(1)
console.log(numAndStrState.getState())
// E strings...
numAndStrState.setState('foo')
console.log(numAndStrState.getState())
// Isso NÃO é o que queremos.
// Queremos criar um estado com apenas números
// e outro com apenas strings

Tentativa 2: Usando Generics

function makeState<S>() {
let state: S
function getState() {
return state
}
function setState(x: S) {
state = x
}
return { getState, setState }
}

makeState() agora é definido como makeState<S>(). Você pode pensar em <S> como outra coisa que você deve passar ao chamar a função. Mas, ao invés de passar um valor, você passa um tipo para ele.

Por exemplo, você pode passar o tipo number como S quando você chama makeState():

// Agora "S" é uma referência para "number" 
makeState<number>()

Então, dentro da definição da função makeState(), S se tornará number:

...
// No corpo da função makeState()...
let state: S // <- number
function setState(x: S /* <- number */) {
state = x
}
...

Porque state será number e setState aceita apenas number, ele cria um estado apenas de números.

// Cria um estado de apenas números
const numState = makeState<number>()
numState.setState(1)
console.log(numState.getState())
numState.setState('foo') // irá falhar!

Por outro lado, para criar um estado somente de string, você pode passar string como S quando você chama makeState():

// Cria um estado de apenas strings
const strState = makeState<string>()
strState.setState('foo')
console.log(strState.getState())
strState.setState(1) // irá falhar!

Nota: Chamamos makeState<S>() de "Função Genérica", porque é literalmente genérica - você pode optar por torná-la somente número ou apenas string. E agora, você sabe o que é uma função genérica quando a função recebe um parâmetro de tipo!

// `makeState` agora é uma função genérica
function makeState<S>() {
let state: S
function getState() {
return state
}
function setState(x: S) {
state = x
}
return { getState, setState }
}

Você pode estar se perguntando: por que nomeamos o parâmetro de tipo como “S”?

Resposta: Na verdade, pode ser qualquer nome, mas geralmente as pessoas usam a primeira letra de uma palavra que descreve o que o tipo está representando. Nesse caso, eu escolhi “S” porque está descrevendo o tipo de “state”. Os seguintes nomes também são comuns:

  • T (para “type”)
  • E (para “element”)
  • K (para “key”)
  • V (para “value”)

Problema: Você pode criar um estado booleano!

// Cria um estado de apenas booleanos
const boolState = makeState<boolean>()
boolState.setState(true)
console.log(boolState.getState())

Talvez, queremos que isso não seja permitido. Suponha que não queremos que makeState() consiga criar estados que não sejam de número ou de strings (como booleanos). Como podemos garantir isso?

A solução: quando você declara makeState(), você altera o parâmetro de tipo <S> para <S extends number | string>. Essa é a única alteração que você precisa fazer.

function makeState<S extends number | string>()

Ao fazer isso, quando você chama makeState(), você só pode passar number, string ou qualquer outro tipo que estenda number ou string como S.

Vamos ver o que acontece agora quando você tenta passar um booleano:

function makeState<
S extends number | string
>() {
let state: S
function getState() {
return state
}
function setState(x: S) {
state = x
}
return { getState, setState }
}
// O que acontece se passarmos booleano para S?
const boolState = makeState<boolean>()

Isso resulta em um erro, que é o que queremos! Prevenimos com sucesso o makeState() de criar estados que não são de número ou de strings.

Como você acabou de ver, você pode especificar o que é permitido para os parâmetros de tipo de uma função genérica.

Tipo Padrão

Então, aqui está uma idéia: podemos dizer que <number> é o parâmetro de tipo padrão de makeState()?

Queremos fazer com que, se S não for especificado, ele seja definido como number por padrão.

// Podemos dizer que <number> é 
// o tipo padrão de makeState()?
// Queremos que o seguinte seja equivalente
const numState1 = makeState()
const numState2 = makeState<number>()

Para que isso aconteça, podemos especificar o tipo padrão de Sadicionando = number no final. É como definir valores padrão para parâmetros de função regulares, certo?

// Setamos o tipo padrão de S para number
function makeState<
S extends number | string = number
>()

Ao fazer isso, você pode criar um estado apenas de número sem especificar o tipo:

// Não precisamos usar <number>
const numState = makeState()
numState.setState(1)
console.log(numState.getState())

Recapitulação rápida: Iguais aos parâmetros de função regulares

O que você deve se lembrar é que Generics são como parâmetros de função regulares. A diferença é que parâmetros de função regulares lidam com valores, mas Generics lidam com parâmetros de tipo.

Exemplo 1: Por exemplo, aqui está uma função regular que aceita qualquer valor:

// Declara uma função regular
function regularFunc(x: any) {
// Você pode usar o valor x aqui
}
// x será 1
regularFunc(1)

Da mesma forma, você pode declarar uma função genérica com um parâmetro de tipo:

// Declara uma função genérica
function genericFunc<T>() {
// Você pode usar o tipo T aqui
}
// T será um "number"
genericFunc<number>()

Exemplo 2: Em funções regulares, você pode especificar o tipo de um parâmetro como este:

// Declara que x será um número
function regularFunc(x: number)
// Sucesso
regularFunc(1)
// Error
regularFunc('foo')

Da mesma forma, você pode especificar o que é permitido para o parâmetro de tipo de uma função genérica:

// Limita o tipo de T
function genericFunc<T extends number>()
// Sucesso
genericFunc<number>()
// Error
genericFunc<string>()

Exemplo 3: Em funções regulares, você pode especificar o valor padrão de um parâmetro como este:

// Declara o valor padrão de x
function regularFunc(x = 2)
// x terá o valor 2 dentro da função
regularFunc()

Da mesma forma, você pode especificar o tipo padrão para uma função genérica:

// Declara o tipo padrão de T
function genericFunc<T = number>()
// T será "number" dentro da função
genericFunc()

Os Generics não são assustadores. Eles são como parâmetros de função regulares, mas, em vez de valores, eles são tipos. Se você entendeu até aqui, você está pronto!

Vamos falar sobre makePair()

function makePair() {
// Guarda um par de valores
let pair: { first: number; second: number }
function getPair() {
return pair
}
// Guarda x como "first" e y como "second"
function setPair(x: number, y: number) {
pair = {
first: x,
second: y
}
}
return { getPair, setPair }
}

Vamos experimentar! O que é impresso no console quando você executa o código a seguir?

const { getPair, setPair } = makePair()setPair(1, 2)
console.log(getPair())
setPair(3, 4)
console.log(getPair())

Agora, assim como fizemos para makeState(), vamos transformar makePair() em uma função genérica.

Tornando makePair uma função genérica

  • São necessários dois parâmetros de tipo, F e S (o "F" para "first" e "S" para "second").
  • O tipo de “first” será F
  • O tipo de “second” será S
function makePair<F, S>() {
let pair: { first: F; second: S }
function getPair() {
return pair
}
function setPair(x: F, y: S) {
pair = {
first: x,
second: y
}
}
return { getPair, setPair }
}

Aqui está um exemplo de uso. Ao chamar makePair com <number, string>, irá declarar first para ser number e o second para ser string.

// Cria um par "number"/"string"
const { getPair, setPair } = makePair<
number,
string
>()
// Deve passar "number" e "string"
setPair(1, 'hello')

Para resumir, você pode criar uma função genérica que leva parâmetros de tipo múltiplo.

// makeState() tem 1 parâmetro de tipo
function makeState<S>()
// makeState() tem 2 parâmetro de tipo
function makePair<F, S>()

Obviamente, você também pode usar a palavra-chave extends ou os tipos padrão como antes:

function makePair<
F extends number | string = number,
S extends number | string = number
>()

Você pode até fazer com que o segundo tipo (S) seja relacionado ao primeiro tipo (F). Aqui está um exemplo:

// O parâmetro "second"/"S" precisa ser
// "boolean" ou qualquer valor de "F"
function makePair<
F extends number | string,
S extends boolean | F
>()
// Esses irão funcionar
makePair<number, boolean>()
makePair<number, number>()
makePair<string, boolean>()
makePair<string, string>()
// Esse irá falhar pois o parâmetro "second"
// precisa ser "boolean" ou "number" mas
// o tipo "string" foi passado
makePair<number, string>()

Interfaces Genéricas e Aliases de Tipo

function makePair<F, S>() {
let pair: { first: F; second: S }
// ...
}

Isso funciona, mas se quisermos, podemos refatorar { first: F, second: S } em uma interface ou um alias de tipo para que possa ser reutilizado.

Vamos primeiro extrair o tipo de pair em uma interface genérica. Vou usar A e B como nomes de parâmetros de tipo para distingui-los dos parâmetros de tipo de makePair().

// Extraindo os tipos em uma
// interface genérica para
// podermos reutilizá-las.
interface Pair<A, B> {
first: A
second: B
}

Podemos então usar essa interface para declarar o tipo de pair.

function makePair<F, S>() {
// Passamos F para A e S para B
let pair: Pair<F, S>
// ...
}

Ao extrair para uma interface genérica (uma interface que aceita parâmetros de tipo), podemos reutilizá-la em outros lugares, se necessário.

Como alternativa, podemos extraí-lo em um alias de tipo genérico. Para tipos de objetos, os aliases de tipo são basicamente idênticos às interfaces, portanto você pode usar o que preferir.

// Extraindo os tipos para um alias de tipo
// genérico. Nesse caso, eles são idênticos
// as interfaces.
type Pair<A, B> = {
first: A
second: B
}

Para resumir, você pode criar interfaces genéricas e aliases de tipo, assim como você pode criar funções genéricas.

Curiosidade

Para saber mais sobre interfaces vs aliases de tipo, leia esta resposta no StackOverflow. A partir do TypeScript 3.7, que adicionou um suporte para aliases de tipo recursivo, os aliases de tipo podem abranger praticamente todos os casos de uso de interfaces.

Classes Genéricas

function makeState<S>() {
let state: S
function getState() {
return state
}
function setState(x: S) {
state = x
}
return { getState, setState }
}

Podemos transformar makeState() em uma classe genérica chamada State como abaixo. Parece semelhante a makeState(), certo?

class State<S> {
state: S
getState() {
return this.state
}
setState(x: S) {
this.state = x
}
}

Para usar isso, você só precisa passar um parâmetro de tipo na inicialização.

// Passa o parâmetro de tipo na inicialização
const numState = new State<number>()
numState.setState(1)
// Imprime 1
console.log(numState.getState())

Para resumir, classes genéricas são como funções genéricas. Funções genéricas usam um parâmetro de tipo quando chamadas, mas classes genéricas usam um parâmetro de tipo quando são instanciadas.

Nota: Você precisa definir "strictPropertyInitialization": falsena configuração do tsconfig.json para o código acima compilar.

Créditos

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store