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.
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.
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 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.
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.