[ Conteúdo ] Entenda o que é Narrowing no TypeScript e veja o porquê é tão importante.
Introdução
Esse é um conteúdo que eu considero tão importante, eu ainda não encontrei aqui no Tabnews. Narrowing ajuda a entender melhor como funciona por debaixo dos panos, assim, para quem vêm do JavaScript, começa a entender que não é Magia e nem nada do tipo, tem todo um processo por debaixo dos panos.
Introdução ao assunto:
Narrowing refere ao processo de refinamento de um tipo mais preciso no TypeScript em tempo de compilação baseado em certas condições e checagens. TypeScript analise o controle de fluxo para inferir um tipo mais preciso de dado em uma condicional, switch stataments, loops, dentre outros. Isso é muito importante pois adiciona uma comada extra de seguraça ao seu código.
Não entendeu? Irei explicar mais sobre como o TypeScrip se comporta em diversas situações.
Utilizando o TypeOf
O operador unário, typeOF
nos retorna uma string do valor a sua direita. Isso da uma informação simples ao TypeScrip do que fazer em certas situações.
Imagine um caso onde temos uma união de valores como abaixo.
function example(argument: number | string) {
...
}
Acima o argumento argument
pode ser tanto number
quanto string
. Porém, e se eu quiser usar o método slice()
? Esse é um método que não existe em valores do tipo number
, mas existem no tipo strig
. E agora? Vamos testar e ver o que acontece!
function example(argument: number | string) {
argument.slice()
}
Ops... Console:
Argument of type 'string | number' is not assignable to parameter of type 'string'.
Type 'number' is not assignable to type 'string'.
O que esse erro quer dizer? Simples, que o tipo number
não é atribuível ao tipo tipo string
. Isso porque era esperado um tipo string
, já que o slice()
é um método que aceita arrays
e strings
.
Como resolver isso? Ai que entra o typeof
operador:
function example(argument: number | string) {
if(typeof argument === "string") {
argument.slice()
} else {
console.log("the value type is number!")
}
}
O que aconteceu aqui? Simples, primeiro verificamos se é string
com o operador typeof
, se é, utilizamos o método slice
, senão, printamos no console que o valor é do tipo number
.
Ué? mas, eu não entendi o porque o typeScript entende assim, e não da outra forma. Isso é ainda mais simples: Se não é um, então é o outro. Primeiro verifcamos se é uma string
, não é uma string
? então só pode ser number
! Isso é lógica.
Supondo que agora temos três possíveis valores:
function example(argument: number | string | boolean):void {
if(typeof argument === "string") {
argument.slice()
} else if(typeof argumetn === "number"){
console.log("the value type is number!")
} else {
console.log("the value type is boolean!")
}
}
Deu para entender melhor? Se não deu, sinto informar que eu não sei como tornar mais didático. Recomendo que se aprofunde mais em condicionais
, typeof operator
, type unions
, functions
, arguments
, lógica de programação
, estrutura de dados
Typeof conceito
Não tem nada de complexo com o operador typeof
, ele apenas nos retorna o tipo do valor a direita em formato de string. Possíveis valores que typeof
pode retornar para nós:
"string"
"number"
"bigint"
"boolean"
"symbol"
"undefined"
"object"
"function"
Não, eu não estou tangenciado! Quero que note uma coisa. Reparou que falta o null
? Pois então! Quem é um programador experiente, sabe o que ocorre aqui, mas para os menos, fica a dica de ouro: null
é considerado um object
, e isso é como um bug
que não foi e nem vai se resolvido, pois acredito que muitos sistemas iriam quebrar caso mudassem de uma hora pra outra... Segue um exemplo:
function printAll(strs: string | string[] | null) {
if (typeof strs === "object") {
for (const s of strs) {
// 'strs' is possibly 'null'.
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}
Aqui tentamos verificar se strs
é um object
, mas o compilador alerta que é possivelmente null
, pois null
é considerado um object
também! Então, cuidado com esse detalhe!
Como contornar isso? bom, existem várias formas, uma delas e seria:
function printAll(strs: string | string[] | null) {
if (strs !== null && typeof strs !== "string") {
for (const s of strs) {
// 'strs' is possibly 'null'.
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
} else {
// do nothing
}
}
Truthiness
Essa é uma palavra um pouco estranha até mesmo para os nativos americanos, mas sua tradução literal seria: "veracidade".
Ok, mas do que se trata isso? Agora vou explicar como condicionais interpretam valores. Usamos diariamente operadores como !
, &&
, ||
e declarações de if
. O que eles tem em comum? Todos trabalham com valores booleans, mesmo que o valor não seja um boolean. O quê? É isso mesmo! O if
por exemplo analisa o que tem dentro do parêntese, e tenta tornar isso coerente para "o if
entender"
Segue um exemplo:
let num1: number = 0;
if(num1) {
console.log("esse resultado é true");
} else {
console.log("esse resultado é false");
}
console:
"esse resultado é false"
Como o if
entendeu isso? simples: Ele converteu o number
para um valor boolean
, tornando mais coerente para ele mesmo entender como prosseguir com aquilo. Quando não é possível o if
entender o que passamos, um erro será mostrado.
valores que são automaticamente convertidos para false
em um if
:
0
NaN
"" (the empty string)
0n (the bigint version of zero)
null
undefined
Algums valores também são convertidos para true
. É muito comum colocamos um propriedade de um objeto para verificar se ela existe.
interface example {
name: string;
age: number;
}
let example2: example = {name: "Yuno", age: 23}
if(example2.name) {
console.log("essa propriedade existe!")
} else {
console.log("error")
}
[LOG]: "essa propriedade existe!
TypeScript também nos dar algumas formas de converter valores para boolean
, de forma manual:
Boolean("hello"); // type: boolean, value: true
!!"world"; // type: true, value: true
Algumas podem estar querendo embrulhar todo o código em um if
para evitar que ocorram erros ao lidar com null
function printAll(strs: string | string[] | null) {
// !!!!!!!!!!!!!!!!
// DON'T DO THIS!
// KEEP READING
// !!!!!!!!!!!!!!!!
if (strs) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}
Existem várias desvantagens ao fazer isso, como não tratar mais corretamente strings vazias. Esse exemplo veio direto da documentação.
Equality narrowing
É nada mais que utilizar os operadores de comparação ==
, ===
, !=
, !==
e switch
statament.
function example(argument1: string | number, argument2: string | boolean) {
if(argument1 === argument2) {
argument1.toLowerCase();
argument2.toUpperCase()
} else {
console.log(argument1);
console.log(argument2);
}
}
acima estamos checando se argument1
é equivalente ao argument2
, e se for, vai utilizar o bloco do if
, senão, o bloco do else
. Reparou que usamos métodos que apenas strings
utilizam? Bom, isso é porque string
é o único valor em comum entre o argument1
e o argument2
, assim, o TypeScrpt sabe que a primeira condição, os valores são strings
.
Também é possível utilizar de outras formas os operadores de igualdade:
function printAll(strs: string | string[] | null) {
if (strs !== null) {
if (typeof strs === "object") {
for (const s of strs) {
console.log(s);
}
} else if (typeof strs === "string") {
console.log(strs);
}
}
}
Acima, checamos se o valor é null
, e depois checamos se é object
. Não dá nenhum erro, pois a possibilidade de null
, já foi eliminada, portanto, não vai aparecer o que: "the value it's possible null".
Uma dica: Quando verificamos se um valor é diferente de undefined
como neste exemplo: value !== undefined
, também estamos verificando se o valor é diferente de null
. Assim com nesse exemplo aqui: value !== null
também verifica se é diferente de null
e undefined
.
The "in" operator
A palavra chave in
é usada para verificar se um objeto possui uma determinada propriedade. Ou seus protótipos.
Segue o exemplo:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
return animal.swim();
}
return animal.fly();
}
Aqui verificamos se animal
que é um argumento tem o método: swim()
, se tiver, retorna ele, senão, retorna fly()
método.
Para propriedades opcionais, elas ainda estão presentes mesmo que sejam opcionais:
type Fish = { swim: () => void };
type Bird = { fly: () => void };
type Human = { swim?: () => void; fly?: () => void };
function move(animal: Fish | Bird | Human) {
if ("swim" in animal) {
animal; //(parameter) animal: Fish | Human
} else {
animal; //(parameter) animal: Bird | Human
}
Como human
também possui esses métodos, será feito a únião desses tipos.
Instanceof
Instanceof também é um operador typeguard e ele é muito utilizando quando se trabalhando com classes. instanceof
verifica se um objeto é instância de outro, ou seus protótipos.
function logValue(x: Date | string) {
if (x instanceof Date) {
console.log(x.toUTCString()); //(parameter) x: Date
} else {
console.log(x.toUpperCase());
(parameter) x: string
}
}
Assignments
Quando utilizamos operador ternário com múltplas possibilidade de valores, também é um typeguard. Exemplo:
let x = Math.random() < 0.5 ? 10 : "hello world!"; // x = number | string
x = 1; //x: number
console.log(x);
x = "goodbye!"; // x:string
console.log(x);
O que aconteceu aqui? Simples, temos uma condicional usando um operador ternário. Se for verdadeira, vai ser um valor do tipo number
, senão, do tipo string
. Por isso, TypeScript faz a união das possibilidades. Se tentasse atribuir outro valor como boolean
, resultaria em um erro.
Type predicates
Agora vai ficar um pouco mais difícil. Eu particularmente nunca utilizei esse recurso, mas tentarei explicar de uma maneira didática.
Vamos por passos. Segue este código:
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
Temos uma função com um argumento que pode ser tanto um fish
, quanto um bird
. Porém, tem algo estranho ali: pet is Fish
. Nunca vi isso, o que será?
Bom, quando temos algo nesse padrão "nome do argumento" is "valor que queremos verificar", significa que a função vai verificar se pet
é fish
, e se for true
, então pet
será inferido como fish
.
Dentro da função, estamos verificando se o objeto pet possui a propriedade swim
, que é específica dos peixes (Fish). Se a propriedade swim estiver definida (ou seja, se não for undefined), a função retorna true, indicando que pet é um peixe (Fish).
Exemplo na prática usando essa função acima:
let pet = getSmallPet();
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
Aqui, estamos assumindo que getSmallPet() retorna um objeto que pode ser tanto um pássaro (Bird) quanto um peixe (Fish). Se pet for um peixe (de acordo com a função isFish), podemos chamar o método swim sem problemas, pois o TypeScript inferiu que pet é do tipo Fish.
Caso contrário, se pet não for um peixe (ou seja, for um pássaro), chamamos o método fly, pois o TypeScript inferiu que pet é do tipo Bird.
Pode ser útil para filtrar elementos de array, ou algo do tipo. De qualquer forma, é uma feature interessante do TypeScript.
Pausa para um café
Esse artigo já tá ficando um tanto longo, não? Ao menos para mim, está (rsrsrs). Pegue um café, relaxe um pouco a vista antes de seguir.
Discriminated unions
Acima tem vários exemplos, mas todos eles são exemplos básicos, porém, muito importante pois são muito comuns no dia a dia. Agora vamos supor que estamos utilizando uma estrutura mais complexa. Segue o código:
interface Shape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
Temos um interface shape
, com uma propriedade kind
. Repare que não estamos usando string
para typar, por exemplo:
interface Shape {
kind: string
radius?: number;
sideLength?: number;
}
Para esse caso não faz sentido fazer isso, pois queremos que o kind
de shape
seja apenas circle
ou square
. E o que isso significa? Isso significa que estamos descriminando uma string
em específico. Ou é circle
ou square
. Qualquer coisa diferente dessa, mesmo que seja uma string
, vai resultar em um erro. Segue o exemplo:
function handleShape(shape: Shape) {
// oops!
if (shape.kind === "rect") {
// ...
}
}
console.log:
This comparison appears to be unintentional because the types '"circle" | "square"' and '"rect"' have no overlap
Não existe react
no paramêtro kind
, apenas square
e circle
.
Agora vamos tentar aplicar alguma lógica para nossas formas geométricas:
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
//'shape.radius' is possibly 'undefined'.
}
Opa, temos um erro! Isso é porque a propriedade radius
é opcional. Isso signfica que pode ser possivelmente undefined
caso não seja passado.
esse erro é resultado de uma configuração no arquivo tsconfig.ts
chamada de stricNullChecks
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
//'shape.radius' is possibly 'undefined'.
}
}
O erro ainda persiste! Isso quer dizer que o TypeScript ainda não sabe o que queremos fazer. Poderiamos usar o operador no-null
para indicar que shape
esta sim presente.
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius! ** 2;
}
}
Eu não recomendo essa abordagem, pois se o arquivo de configuração não estiver de fato bem configurado, logo ocorrerá diversos problemas.
O problema aqui é que queremos utilizar radius
baseado no tipo de kind
, mas o typeScript não tem como saber isso. Então, poderiamos dividir o código:
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
Agora tanto circle
quanto square
tem seus respectivas propriedades relacionadas. Perdendo a necessidade de torna-las opcionais.
vamos tentar agora:
function getArea(shape: Shape) {
return Math.PI * shape.radius ** 2;
//Property 'radius' does not exist on type 'Shape'.
//Property 'radius' does not exist on type 'Square'.
}
Ainda tem um erro! Agora não porque é possivelmente undefined
, e sim porque shape
pode ter propriedades que não existe em square
, por exemplo. No exemplo acima, estamos tentando acessar radius
que é uma propriedade de circle
, mas como não específicamos que estamos dentro da possibilidade de um circle
, vai resultar em um erro pois square
não tem radius
como propriedade.
function getArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2; //(parameter) shape: Circle
}
}
Agora sim nosso shape
foi inferido de maneira correta!
Essa mesma abordagem funciona com switch
:
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; //(parameter) shape: Circle
case "square":
return shape.sideLength ** 2; //(parameter) shape: Square
}
}
Precisamos especificar o tipo da união para conseguir utilizar com precisão. Isso é muito importante para garantir a qualidade do código e manter ele seguro de possíveis bugs. Muita das abordagens do JavScript precisam de muito adaptação para funcionar em TypeScript, isso porque JavaScript é menos seguro que o TypeScript, logo: é menos rígido.
The never type
O tipo never
é muito interessante. Ele significa que não existe mais nenhuma possibilidade em alguma coisa. Segue o código:
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Aqui adicionamos um default
que explicitamente usa never
que todas as possíveis possibilidade são as que existem dentro de shape
. Se por acaso, tentarmos adicionar um triangle
Por exemplo, resultaria em um erro:
interface Triangle {
kind: "triangle";
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
default:
const _exhaustiveCheck: never = shape;
//Type 'Triangle' is not assignable to type 'never'.
return _exhaustiveCheck;
}
}
Por que deu erro? Simples, estamos falando que todas as possibilidades disponivéis são as que existem dentro de shape
, mas em nenhum momento eu adicionei um case
para Triagle
. Para resolver:
interface Triangle {
kind: "triangle";
sideLength: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "triangle":
return shape.sideLength ** 2
default:
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
Conclusão:
Gostaram do conteúdo? Cometi algum erro? Cometem!