Effective TypeScript: De Declarações a Interfaces

Minissérie que publiquei no Medium sobre o livro – Effective TypeScript

quinta-feira, 18 de janeiro de 2024

Capa do livro Effective TypeScript

Hoje é dia de falar sobre TypeScript. Anteriormente, já mencionei em meu Instagram que comecei a ler o livro "Effective TypeScript". Logo após concluir meus estudos na linguagem pelo site roadmap.sh, meu amigo Dahlton S me indicou essa grande obra.

Nesse livro vemos 62 maneiras específicas de melhorar nosso TypeScript. Meu objetivo com esse livro é poder ter melhores decisões na hora de escrever os tipos, e é isso que trago aqui hoje!

Escolha declarações de tipo em vez de asserções

A linguagem TypeScript é cheia de possibilidades — há muitas coisas que podemos fazer, mas nem sempre são as melhores opções para aquele determinado contexto. Quando estamos falando de asserções de tipo (também chamadas de type assertions), devemos tomar cuidado para não silenciar o type checker sem motivos.

Temos duas maneiras de atribuir um valor a uma variável e definir seu tipo:

type Person = {
  name: string
  age: number
}

const alice: Person = { name: "Alice", age: 23 } // Type is Person
const bob = { name: "Bob", age: 28 } as Person // Type is Person

Embora sejam semelhantes, na verdade são bem diferentes:

const alice: Person = { name: "Alice", age: 23 }

A constante alice usa uma declaração de tipo, que garante que o valor esteja em conformidade com o tipo definido.

const bob = { name: "Bob", age: 28 } as Person

Já a constante bob usa uma asserção de tipo. Isso diz ao TypeScript que, apesar do tipo inferido, você sabe o que está fazendo — e por esse motivo o verificador de tipos é silenciado.

Por que preferir declarações?

Aqui está o motivo concreto para preferir declarações de tipo em vez de asserções de tipo:

type Person = {
  name: string
  age: number
}

const alice: Person = {}
// Type '{}' is missing the following properties from type 'Person': name, age

const bob = {} as Person // Você que manda, tudo ok por aqui!

A declaração de tipo verifica se o valor está em conformidade com o alias de tipo. Como isso não acontece, o TypeScript sinaliza um erro. A asserção de tipo silencia esse erro, dizendo ao verificador que, por qualquer motivo, você sabe melhor do que ele.

O mesmo comportamento ocorre com propriedades extras:

const alice: Person = {
  name: "Alice",
  occupation: "TypeScript Developer",
  // Object literal may only specify known properties,
  // and 'occupation' does not exist in type 'Person'.
}

const bob = {
  name: "Bob",
  occupation: "JavaScript Developer",
} as Person // Ok, você que manda! zero erros.

Quando usar asserções de tipo?

As asserções fazem sentido quando você realmente sabe mais sobre um tipo do que o TypeScript — normalmente a partir de um contexto que não está disponível para o verificador de tipo.

Por exemplo, você pode conhecer o tipo de um elemento do DOM com mais precisão:

document.querySelector("#myButton").addEventListener("click", (e) => {
  e.currentTarget // Type is EventTarget
  const button = e.currentTarget as HTMLButtonElement
  button // Type is HTMLButtonElement
})

Como o TypeScript não tem acesso ao DOM da sua página, não há como saber que #myButton é um elemento de botão, nem que o currentTarget do evento deveria ser esse mesmo botão. Aqui, a asserção de tipo é justificada.

Os limites do excesso de verificação de propriedades

Quando você atribui um objeto literal diretamente a uma variável tipada, o TypeScript aplica a verificação excessiva de propriedades (excess property checking), garantindo que ele tenha exatamente as propriedades daquele tipo — nem mais, nem menos:

interface Room {
  numDoors: number
  ceilingHeightFt: number
}

const myRoom: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
  // Object literal may only specify known properties,
  // and 'elephant' does not exist in type 'Room'.
}

Este erro pode parecer estranho do ponto de vista da tipagem estrutural. Afinal, a constante é atribuível ao tipo Room. Mas veja o que acontece ao introduzir uma variável intermediária:

const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
}

const myRoom: Room = obj // Ok, sem erros

O tipo de obj é inferido como { numDoors: number; ceilingHeightFt: number; elephant: string }. Como esse tipo inclui um subconjunto de valores do tipo Room, ele pode ser atribuído a Room e o código passa no verificador.

Por que isso acontece?

No primeiro exemplo, o TypeScript ativou a verificação excessiva de propriedades, que detecta uma série importante de erros que o sistema de tipos estruturais, por si só, não perceberia. Mas esse processo tem seus limites — combiná-lo com verificações de atribuibilidade pode dificultar a construção de uma intuição sobre a tipagem estrutural.

Reconhecer isso como um processo distinto vai te ajudar a construir um modelo mental mais claro do sistema de tipos do TypeScript. Para se aprofundar, pesquise por "compatibilidade de tipos" — a documentação oficial é o melhor ponto de partida.

Aplique tipos a expressões de função quando possível

Em JavaScript e TypeScript, funções statement e funções expression são distintas:

function sum(x: number, y: number) {} // statement
const multiply = function (x: number, y: number) {} // expression

A vantagem das expressões de função no TypeScript é que você pode aplicar uma declaração de tipo para toda a função de uma só vez, em vez de especificar os tipos dos parâmetros e o tipo de retorno individualmente:

type sumTwoPoints = (x: number, y: number) => number
const sum: sumTwoPoints = (x, y) => x + y

Isso torna o código mais conciso e facilita a reutilização de assinaturas de função em múltiplos lugares.

Desmistificando type e interface

Qual você deve usar, type ou interface? Essa é uma das perguntas mais comuns entre desenvolvedores TypeScript. Para responder com clareza, vamos primeiro às semelhanças:

O que ambos suportam

Index signatures:

type TypeDict = { [key: string]: string }

interface InterfaceDict {
  [key: string]: string
}

Tipos de função:

type TypeFunction = (x: number) => string

interface InterfaceFunction {
  (x: number): string
}

Genéricos:

type TypePair<T> = {
  first: T
  second: T
}

interface InterfacePair<T> {
  first: T
  second: T
}

Extensão mútua — uma interface pode estender um type, e um type pode estender uma interface:

interface InterfaceStateWithPop extends TypeState {
  population: number
}

type TypeStateWithPop = InterfaceState & { population: number }

Esses resultados são idênticos. A diferença é que uma interface não pode estender um tipo complexo, como um tipo de união — para isso, você precisará usar type com &.

Implementação em classes:

class StateType implements TypeState {
  name: string = ""
  capital: string = ""
}

class StateInterface implements InterfaceState {
  name: string = ""
  capital: string = ""
}

O que só type suporta

Existem tipos de união, mas não há interfaces de união:

type AorB = "a" | "b"

Isso pode ser muito útil. Se você tiver tipos separados para variáveis de entrada e saída e quiser um mapeamento por nome:

type Input = {}
type Output = {}

interface VariableMap {
  [name: string]: Input | Output
}

E quiser um tipo que anexe o nome à variável:

type NamedVariable = (Input | Output) & { name: string }

Esse tipo não pode ser expresso com interface. Em geral, type é mais poderoso: suporta uniões, tipos mapeados e tipos condicionais.

O que só interface suporta

Uma interface possui uma habilidade exclusiva: ela pode ser aumentada (augmented). Isso é chamado de declaration merging:

interface InterfaceState {
  name: string
  capital: string
}

interface InterfaceState {
  population: number
}

const wyoming: InterfaceState = {
  name: "Wyoming",
  capital: "Cheyenne",
  population: 500_000,
} // Tudo ok por aqui!

Duas declarações com o mesmo nome são mescladas automaticamente pelo TypeScript. Isso é bastante útil para estender tipos de bibliotecas externas, por exemplo.

Então, qual usar?

  • Para tipos complexos (uniões, tipos mapeados, condicionais): use type, sem exceção.
  • Para tipos de objetos simples: considere a consistência da base de código. Se o projeto usa interface de forma consistente, siga com interface. Se usa type, siga com type.

Obrigado pela leitura até aqui! Espero que este conteúdo seja útil de alguma forma. Este post tem como referência os assuntos abordados nos capítulos 1 e 2 do livro Effective TypeScript.