Fundamentos do Type Guards - TypeScript Narrowing #2

TypeScript Narrowing #2

Ei!

Bem-vindo ao segundo artigo da nossa série de narrowing em TypeScript, onde cobriremos desde fundamentos até casos de uso avançados.

LinkNosso objetivo

Neste artigo, eu quero mostrar a vocês os type guards fundamentais na prática. Para fazer isso, vamos construir uma função que formata mensagens de erro antes de mostrá-las ao usuário final.

TypeScript
const formatErrorMessage =
    (value: ???): string => { ... }

Nossa função deve ser capaz de receber vários tipos diferentes e retornar uma mensagem de erro formatada.

TypeScript
const formatErrorMessage =
    (value: null | undefined | string | Error | Warning): string => { ... }

Você está pronto? 🥁

LinkO Operator Guard typeof

Começaremos oferecendo suporte a apenas dois tipos: string e Error.

TypeScript
const formatErrorMessage =
    (value: string | Error): string => { ... }

Para implementar essa função, precisamos estreitar a string | Error para apenas uma string e lidar com isso, então nós estreitamos para apenas um Error e lidamos com ele.

TypeScript
const formatErrorMessage = (value: string | Error): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo

  // Se for um erro, retorne o Error.message com o prefixo
};

O primeiro type guard que vamos explorar é o typeof operator. Este operador nos permite verificar se um determinado valor é um:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

Então, vamos usá-lo em nossa função.

TypeScript
const formatErrorMessage = (value: string | Error): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value; // <- value: string
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message; // <- value: Error
};

O que está acontecendo aqui é que a instrução typeof value === 'string' está atuando como um type guard para string. TypeScript sabe que a única maneira do código dentro desse if executar é se o valor for uma string. Portanto, ele estreita o tipo para string dentro do bloco if.

Já que estamos retornando algo, value não pode ser uma string após a instrução if, portanto, o único tipo que resta é Error.

LinkO Operator Guard in

Às vezes, não temos tanta sorte. Por exemplo, vamos adicionar um novo tipo à nossa função, uma interface personalizada chamada Warning.

TypeScript
const formatErrorMessage = (value: string | Error): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value; // <- value: string
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message; // <- value: Error
};

interface Warning {
  text: string;
}

Agora o nosso código está quebrado.

TypeScript
const formatErrorMessage = (value: string | Error | Warning): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value; // <- value: string
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message; // <- value: Error | Warning
};

interface Warning {
  text: string;
}

Antes, nossa variável value só poderia ser uma instância da classe Error após o if.

TypeScript
const formatErrorMessage = (value: string | Error): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value; // <- value: string
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message; // <- value: Error
};

interface Warning {
  text: string;
}

Mas agora, pode ser um Error | Warning, e a propriedade .message não existe em um Warning.

TypeScript
const formatErrorMessage = (value: string | Error | Warning): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value; // <- value: string
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message; // <- value: Error | Warning
};

interface Warning {
  text: string;
}

O operador typeof não vai nos ajudar aqui, porque typeof value seria object para ambos os casos.

TypeScript
const formatErrorMessage =
    (value: string | Error | Warning): string => {
        const prefix = 'Error: ';

        // Se for uma string, retorna a string com o prefixo
        if (typeof value === 'string') {
            return prefix + value // <- value: string
        }

        // Se for um Warning, retorne o Warning.text com o prefixo
        if (???) {
            return prefix + value.text
        }

        // Se for um erro, retorne o Error.message com o prefixo
        return prefix + value.message // <- value: Error | Warning
}

interface Warning {
    text: string
}

Uma das maneiras idiomáticas de lidar com essa situação em JavaScript seria verificar se value tem a propriedade .text. Se tiver, é um Warning. Podemos fazer isso com o operador in.

TypeScript
const formatErrorMessage = (value: string | Error | Warning): string => {
  const prefix = 'Error: ';

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value; // <- value: string
  }

  // Se for um Warning, retorne o Warning.text com o prefixo
  if ('text' in value) {
    return prefix + value.text; // <- value: Warning
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message; // <- value: Error
};

interface Warning {
  text: string;
}

Este operador retorna verdadeiro se o objeto fornecido possui a propriedade fornecida. Nesse caso, se value tiver a propriedade .text.

TypeScript sabe que o nosso if só será verdadeiro se value for um Warning porque esse é o único tipo possível para value que tem uma propriedade chamada .text, portanto, ele estreita o tipo para Warning dentro do bloco if.

Após o primeiro if, value pode ser Warning | Error. Após o segundo if, só pode ser Error.

LinkEstreitamento por igualidade

Também é muito comum oferecer suporte a argumentos opcionais, isso significa, permitir que value seja null ou undefined.

TypeScript
const formatErrorMessage =
    (value: null | undefined | string | Error | Warning): string => {
        const prefix = 'Error: ';

    // Se for null ou indefinido, retorna "Unknown" com o prefixo
    if (???) {
            return prefix + 'Unknown'
        }

        // Se for uma string, retorna a string com o prefixo
        if (typeof value === 'string') {
            return prefix + value // <- value: string
        }

        // Se for um Warning, retorne o Warning.text com o prefixo
        if ('text' in value) {
            return prefix + value.text // <- value: Warning
        }

        // Se for um erro, retorne o Error.message com o prefixo
        return prefix + value.message // <- value: Error
}

interface Warning {
    text: string
}

Nós poderíamos lidar com undefined usando o operador typeof, mas isso não funcionaria com null.

A propósito, se você quer saber por que não funcionaria para null e as diferenças entre null e undefined, Tenho um artigo muito curto e informativo explicando exatamente isso. Vou deixar um link para ele nas referências.

O que poderíamos fazer que funcionaria para null e undefined é usar operadores de igualdade, como ===:

TypeScript
const formatErrorMessage = (
  value: null | undefined | string | Error | Warning
): string => {
  const prefix = 'Error: ';

  // Se for null ou indefinido, retorna "Unknown" com o prefixo
  if (value === null || value === undefined) {
    return prefix + 'Unknown';
  }

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value;
  }

  // Se for um Warning, retorne o Warning.text com o prefixo
  if ('text' in value) {
    return prefix + value.text;
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message;
};

interface Warning {
  text: string;
}

Nosso if só será verdadeiro se o valor for igual a null ou undefined, então TypeScript estreita o nosso tipo a null | undefined.

Isso é chamado estreitamento por igualdade e também funciona com outros operadores de comparação, tais como:

  • Diferente !==
  • Igual amplo ==
  • Diferente amplo !=

LinkEstreitamento por truthiness

Mas é o seguinte, estreitamento por igualdade não é a forma idiomática do JavaScript de verificar se há null | undefined. A maneira idiomática de fazer isso é verificar se value é truthy.

Eu tenho um artigo curto explicando o que é truthy e falsy em JavaScript. Vou colocar o link nas referências. Seria bom se você pudesse lê-lo antes de prosseguirmos, para ter as definições de truthy e falsy frescas na mente. Vá em frente, estou esperando.

Agora que todos nós temos as definições de truthy e falsy frescas em nossas mentes, deixe-me lhe apresentar o estreitamento por truthiness.

Em vez de usar o estreitamento por igualdade para verificar se value é igual a null ou undefined, podemos apenas verificar se ele é falsy.

TypeScript
const formatErrorMessage = (
  value: null | undefined | string | Error | Warning
): string => {
  const prefix = 'Error: ';

  // Se for falso (null, undefined, string vazia), retorne "Unknown" com o prefixo
  if (!value) {
    return prefix + 'Unknown';
  }

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value;
  }

  // Se for um Warning, retorne o Warning.text com o prefixo
  if ('text' in value) {
    return prefix + value.text;
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message;
};

interface Warning {
  text: string;
}

Podemos fazer isso prefixando-o com um NOT lógico !. Isso converterá value em um booleano e o inverterá. Se for falsy, será convertido para falso e depois invertido para verdadeiro.

LinkAnálise de Fluxo de Controle

Até agora, evitamos um guarda para verificar se value é uma instância da classe Error. Eu disse como estamos conseguindo fazer isso. Estamos tratando todos os tipos possíveis, de modo que só resta o tipo Error no final.

Essa técnica é muito comum em JavaScript e também é uma forma de estreitamento. O termo correto para o que temos feito é "análise do fluxo de controle" (Control Flow Analysis).

A análise do fluxo de controle é a análise do nosso código com base no seu alcance.

TypeScript sabe que não podemos alcançar a primeira instrução if, se value não for falsy.

TypeScript
const formatErrorMessage = (
  value: null | undefined | string | Error | Warning
): string => {
  const prefix = 'Error: ';

  // Se for falso (null, undefined, string vazia), retorne "Unknown" com o prefixo
  if (!value) {
    return prefix + 'Unknown';
  }

  // Se for uma string, retorna a string com o prefixo
  // if (typeof value === 'string') {
  // return prefix + value
  // }

  // Se for um Warning, retorne o Warning.text com o prefixo
  // if ('text' in value) {
  // return prefix + value.text
  // }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message;
};

interface Warning {
  text: string;
}

Não podemos alcançar o segundo if, se value não for uma string.

TypeScript
const formatErrorMessage = (
  value: null | undefined | string | Error | Warning
): string => {
  const prefix = 'Error: ';

  // Se for falso (null, undefined, string vazia), retorne "Unknown" com o prefixo
  if (!value) {
    return prefix + 'Unknown';
  }

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value;
  }

  // Se for um Warning, retorne o Warning.text com o prefixo
  // if ('text' in value) {
  // return prefix + value.text
  // }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message;
};

interface Warning {
  text: string;
}

Não podemos alcançar o terceiro, se não for um Warning. Então, no final, só resta um tipo: só pode ser um Error.

TypeScript
const formatErrorMessage = (
  value: null | undefined | string | Error | Warning
): string => {
  const prefix = 'Error: ';

  // Se for falso (null, undefined, string vazia), retorne "Unknown" com o prefixo
  if (!value) {
    return prefix + 'Unknown';
  }

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value;
  }

  // Se for um Warning, retorne o Warning.text com o prefixo
  if ('text' in value) {
    return prefix + value.text;
  }

  // Se for um erro, retorne o Error.message com o prefixo
  return prefix + value.message;
};

interface Warning {
  text: string;
}

Esses tipos estão sendo estreitados porque o TypeScript está usando a análise do fluxo de controle.

LinkO Operator Guard instanceof

Mas não precisamos depender da análise do fluxo de controle para estreitar o nosso tipo a Error. Podemos fazer isso com um operador muito simples e idiomático do JavaScript. O operador instanceof.

TypeScript
const formatErrorMessage = (
  value: null | undefined | string | Error | Warning
): string => {
  const prefix = 'Error: ';

  // Se for falso (null, undefined, string vazia), retorne "Unknown" com o prefixo
  if (!value) {
    return prefix + 'Unknown';
  }

  // Se for uma string, retorna a string com o prefixo
  if (typeof value === 'string') {
    return prefix + value;
  }

  // Se for um Warning, retorne o Warning.text com o prefixo
  if ('text' in value) {
    return prefix + value.text;
  }

  // Se for um erro, retorne o Error.message com o prefixo
  if (value instanceof Error) {
    return prefix + value.message;
  }

  // Nós nunca chegaremos aqui
  throw new Error(`Invalid value type`);
};

interface Warning {
  text: string;
}

Aqui estamos verificando se o valor é uma instância da classe Error, portanto, o TypeScript estreita nosso tipo a Error. Não sobrou nenhum tipo após a última instrução if, nunca alcançaremos nenhum código que venha depois disso.

LinkTipo never (15s)

Se você está se perguntando o que o TypeScript considera ser o tipo value depois de todos os nossos ifs, a resposta é never.

never (nunca) é um tipo especial que representa algo que é impossível, algo que nunca deveria acontecer.

LinkConclusão

Esses foram os type guards fundamentais, eles são super úteis, mas só vão te levar até certo ponto. Nos próximos artigos, mostrarei como criar type guards customizados. Inscreva-se para não perder.

As referências estão abaixo.

E se sua empresa está precisando de desenvolvedores web remotos, você pode entrar em contato comigo e com a minha equipe em lucaspaganini.com.

Como sempre, tenha um ótimo dia e nos vemos em breve!

LinkConteúdo Relacionado

  1. 1min JS - Falsy and Truthy
  2. Null vs Undefined in JavaScript - Explained Visually
  3. TypeScript Narrowing Part 1 - What is a Type Guard

LinkReferências

  1. Narrowing Documentação do TypeScript
  2. O tipo never Documentação do TypeScript
  3. O Operador instanceof na MDN
  4. O Operador typeof na MDN
  5. O Operador in na MDN

Assine a nossa Newsletter e seja avisado quando eu lançar um curso, postar um vídeo ou escrever um artigo.

Campo obrigatório
Campo obrigatório