Wesley Damasceno

Effective TS: Liberalidade, Uniões de Interfaces e Tipos de String

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

jan 22, 2024

Continuando nossa jornada pela série de postagens inspiradas no livro "Effective TypeScript", exploraremos agora o capítulo 4. Até o momento, discutimos alguns tópicos do capítulo 3, como usar variáveis separadas para tipos diferentes, o que é o type widening e o que é type narrowing.

Jon Postel com seu diagrama da Internet emergente

Se ainda não conferiu a segunda postagem, clique aqui para ler.

Seja liberal no que você aceita e rigoroso no que você produz


Vamos começar com o princípio da robustez, conhecido como "a Lei de Postel". Esse conceito destaca a importância de ser cauteloso ao realizar ações, mas mais flexível ao lidar com dados de terceiros.

No contexto das funções, enfatiza que é aceitável receber uma vários tipos de entradas de dados, mas desejável ser mais específico nos retornos.

Vamos para um exemplo prático usando uma API de mapeamento 3D:

declare function setCamera(camera: CameraOptions): void
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions

A função setCamera recebe opções de câmera, enquanto viewportForBounds calcula a área visível para uma caixa delimitadora. O problema surge quando viewportForBounds retorna um tipo muito amplo e complexo, tornando seu uso mais difícil.

Para resolver isso, o autor sugere a introdução de formas "canônicas" para coordenadas, diferenciando Coordinate e CoordinateLike. Além disso, são estabelecidas distinções entre um tipo de câmera totalmente definido e uma versão parcial aceita por setCamera:

interface Coordinate {
  longitude: number
  latitude: number
}

type CoordinateLike =
  | {
      lon: number
      lat: number
    }
  | Coordinate
  | [number, number]

interface Camera {
  center: Coordinate
  zoom: number
  direction: number
  pitch: number
}

interface CameraOptions extends Omit<Partial<Camera>, "center"> {
  center?: CoordinateLike
}

type LngLatBounds =
  | {
      northeast: CoordinateLike
      southwest: CoordinateLike
    }
  | [CoordinateLike, CoordinateLike]
  | [number, number, number, number]

declare function setCamera(options: CameraOptions): void
declare function viewportForBounds(bounds: LngLatBounds): Camera

Essa abordagem de criar formas "canônicas" para os tipos de retorno e formas mais flexíveis para os parâmetros torna o uso dessas funções mais fácil e intuitivo. O objetivo é ampliar as possibilidades de entrada, enquanto se mantém precisão na saída.


Prefira uniões de interfaces a interfaces de uniões


A preferência por uniões de interfaces em vez de interfaces de uniões defende a modelagem de tipos mais precisos por meio de uniões de interfaces específicas, em vez de criar interfaces que contenham propriedades com tipos de união.

Suponha que você esteja construindo um programa de desenho vetorial e queira definir uma interface para camadas com tipos específicos:

interface Layer {
  layout: FillLayout | LineLayout | PointLayout
  paint: FillPaint | LinePaint | PointPaint
}

O campo layout controla como e onde as formas são desenhadas (cantos arredondados? retos?), enquanto o campo paint controla os estilos (a linha é azul? grossa? fina? tracejada?).

Faria sentido ter uma camada cujo layout fosse LineLayout, mas cuja propriedade paint fosse FillPaint? Provavelmente não. Permitir essa possibilidade torna a biblioteca mais sujeita a erros. A melhor maneira de modelar isso é com interfaces separadas para cada tipo de camada:

interface FillLayer {
  layout: FillLayout
  paint: FillPaint
}

interface LineLayer {
  layout: LineLayout
  paint: LinePaint
}

interface PointLayer {
  layout: PointLayout
  paint: PointPaint
}

type Layer = FillLayer | LineLayer | PointLayer

Ao definir Layer dessa forma, você exclui a possibilidade de propriedades layout e paint misturadas. Esse também é um exemplo de preferir criar tipos que representem apenas estados válidos.

O padrão de união marcada


O exemplo mais comum deste padrão é chamado de "tagged union" (ou "união marcada"). Neste caso, uma das propriedades é uma união de tipos literais de string:

interface Layer {
  type: "fill" | "line" | "point"
  layout: FillLayout | LineLayout | PointLayout
  paint: FillPaint | LinePaint | PointPaint
}

Faria sentido ter type: 'fill' mas com um LineLayout e PointPaint? Também não. Convertemos Layer em uma união de interfaces para excluir essa possibilidade:

interface FillLayer {
  type: "fill"
  layout: FillLayout
  paint: FillPaint
}

interface LineLayer {
  type: "line"
  layout: LineLayout
  paint: LinePaint
}

interface PointLayer {
  type: "paint"
  layout: PointLayout
  paint: PointPaint
}

type Layer = FillLayer | LineLayer | PointLayer

A propriedade type é a "tag" e pode ser usada para determinar com qual tipo de camada você está trabalhando em tempo de execução. O TypeScript também é capaz de restringir o tipo de camada com base nessa tag:

function drawLayer(layer: Layer) {
  if (layer.type === "fill") {
    const { paint } = layer // Type is FillPaint
    const { layout } = layer // Type is FillLayout
  } else if (layer.type === "line") {
    const { paint } = layer // Type is LinePaint
    const { layout } = layer // Type is LineLayout
  } else {
    const { paint } = layer // Type is PointPaint
    const { layout } = layer // Type is PointLayout
  }
}

Ao modelar corretamente o relacionamento entre as propriedades deste tipo, você ajuda o TypeScript a verificar a exatidão do seu código. Por funcionarem tão bem com o verificador de tipos, as uniões marcadas são onipresentes no código TypeScript.

Reconheça esse padrão e aplique-o quando puder. Se você puder representar um tipo de dados com uma tagged union, geralmente é uma boa ideia fazê-lo.


Alternativas precisas em tipos de string


Suponha que esteja construindo uma coleção de músicas e queira definir o tipo para um álbum:

interface Album {
  artist: string
  title: string
  releaseDate: string // YYYY-MM-DD
  recordingType: string // Exemplo, "ao vivo" ou "estúdio"
}

A prevalência de string e a presença de informações de tipo em comentários são fortes indicações de que essa interface não está totalmente correta. Veja o que pode dar errado:

const kindOfBlue: Album = {
  artist: "Miles Davis",
  title: "Kind of Blue",
  releaseDate: "17 de agosto de 1959", // Ops!
  recordingType: "Estúdio", // Ops!
} // OK

O campo releaseDate está formatado incorretamente e "Estúdio" está em maiúsculas quando deveria estar em minúsculas. Mas ambos os valores são string, então o objeto é atribuível a Album e o verificador de tipos não reclama. Códigos como esse são às vezes chamados de "stringly typed".

Para tornar os tipos mais restritos, usamos Date para releaseDate e um tipo de união literal para recordingType:

type RecordingType = "estúdio" | "ao vivo"

interface Album {
  artist: string
  title: string
  releaseDate: Date
  recordingType: RecordingType
}

Com essas alterações, o TypeScript passa a realizar verificações muito mais rigorosas:

const kindOfBlue: Album = {
  artist: "Miles Davis",
  title: "Kind of Blue",
  releaseDate: new Date("1959-08-17"),
  recordingType: "Estúdio",
  // Type 'Estúdio' is not assignable to type 'RecordingType'
}

A capacidade do TypeScript de definir subconjuntos de string é uma maneira poderosa de trazer segurança de tipo ao código JavaScript.

O uso de tipos mais precisos captura esses erros e melhora a legibilidade do código.