Guardas de tipo assíncrono - TypeScript Narrowing #7

TypeScript Narrowing #7

Olá! Bem-vindo à parte 7 da nossa série sobre TypeScript Narrowing.

Antes de começar, me deixe lhe dizer uma coisa: É recomendável que você leia os artigos em sequência, mas você não precisa assistir todos os artigos para entender este aqui. O que você precisa, é ler as partes 3 e 6, do contrário, você provavelmente vai se sentir perdido aqui, ok?

Hoje vamos falar sobre um recurso muito solicitado no TypeScript: Type guards assíncronos.

Só temos um pequeno problema: ainda é apenas um recurso solicitado, não existe suporte oficial para type guards assíncronos ainda.

Mas, como desenvolvedores, não podemos apenas esperar que outra pessoa resolva nossos problemas. Às vezes (na maioria delas), precisamos encontrar uma solução por conta própria, com as ferramentas que temos disponíveis atualmente.

E é exatamente isso que vamos fazer. Eu vou te mostrar a solução mais limpa que eu consegui criar para termos type guards assíncronos. Essa solução também será fácil de refatorar assim que tivermos suporte nativo para type guards assíncronos no TypeScript.

Se você tiver uma solução diferente, deixe um comentário e podemos discutir sobre ela.

Eu sou Lucas Paganini e nesse blog lançamos vídeos tutoriais de desenvolvimento web.

LinkO objetivo

Em um mundo ideal, estaríamos escrevendo funções assíncronas que retornam um predicado de tipo envolto em uma Promise.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<value is string> =>
    typeof value === "string"

Ainda não podemos fazer isso, mas podemos nos esforçar para chegar o mais perto possível disso. Portanto, vamos procurar uma solução alternativa que se aproxime dessa implementação ideal.

Aliás, talvez você esteja se perguntando, por que deveríamos verificar de forma assíncrona se um valor é uma string. Bem, eu também me pergunto, por que faríamos isso?

O fato é que eu tenho um caso de uso real em que type guards assíncronos seriam úteis, e eu vou te mostrar esse cenário. Mas, primeiro quero que você entenda a nossa implementação de type guards assíncronos. Então, vamos continuar com a nossa função fictícia isStringAsync por enquanto.

LinkA Solução Alternativa

Atualmente, não podemos retornar um predicado de tipo envolto em uma Promise, mas podemos retornar um boolean envolto em uma Promise.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<value is string> =>
    typeof value === "string"
TypeScript
const isStringAsync = async (value: unknown): Promise<boolean> =>
  typeof value === 'string';

Agora, como você deve ter adivinhado, esse boolean não significa nada para o TypeScript. Ele não executa nenhum estreitamento em nossa variável.

TypeScript
const isStringAsync = async (value: unknown): Promise<boolean> =>
  typeof value === 'string';

const aaa = 1 as number | string | Date;
isStringAsync(aaa).then((isString) => {
  if (isString) {
    aaa; // <- aaa: string | number | Date
  } else {
    aaa; // <- aaa: string | number | Date
  }
});

Isso é... triste. Parece que só podemos ter type guards síncronos...

Mas... calma aí! No último artigo, aprendemos que podemos ter funções que criam type guards. Então talvez possamos ter uma função que, de maneira assíncrona, crie uma função síncrona.

Em vez de retornar a Promise de um predicado de tipo, o que não podemos fazer ainda.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<value is string> =>
    typeof value === "string"

O que podemos fazer é retornar uma Promise de uma função que retorne um predicado de tipo.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<(v: unknown) => v is string> =>
  (v): v is string =>
    typeof value === 'string';

E caramba, isso realmente funciona!

TypeScript
const isStringAsync =
  async (value: unknown): Promise<(v: unknown) => v is string> =>
  (v): v is string =>
    typeof value === 'string';

const aaa = 1 as number | string | Date;
isStringAsync(aaa).then((isString) => {
  if (isString(aaa)) {
    aaa; // <- aaa: string
  } else {
    aaa; // <- aaa: number | Date
  }
});

E está tão perto do cenário ideal, que vai ser muito fácil de refatorar o nosso código assim que tivermos suporte nativo para type guards assíncronos.

TypeScript
const isStringAsync =
  async (value: unknown): Promise<v is string> =>
    typeof value === "string"

const aaa = 1 as number | string | Date
isStringAsync(aaa).then(isString => {
  if (isString) {
    aaa // <- aaa: string
  } else {
    aaa // <- aaa: number | Date
  }
})

LinkMotivações para protetores de tipo assíncrono

Maravilha! Agora que entendemos a implementação, vamos ver um cenário real, onde realmente seria um benefício termos type guards assíncronos. Porque verificar se um valor é uma string de forma assíncrona não é um bom caso de uso.

Vou dar um exemplo inspirado no que Dominik Głodek deu quando fez a solicitação de recurso para type guards assíncronos:

Você está escrevendo um endpoint de API que recebe dados para criar um usuário. Os dados do usuário consistem em um e-mail e uma senha.

TypeScript
interface User {
  email: string;
  password: string;
}

Você só pode salvar usuários no banco de dados se eles forem válidos. Para um usuário ser válido, ele precisa atender a dois critérios:

  1. A senha deve ter pelo menos oito caracteres
  2. O e-mail não pode pertencer a outro usuário.

A primeira verificação pode ser feita de forma síncrona, então vamos começar com ela! Vamos criar uma função de asserção chamada validateUser que verifica se nosso objeto está em conformidade com a interface User, e com a validação de senha.

TypeScript
type ValidateUser = (value: unknown) => asserts value is User;

const validateUser: ValidateUser = (value) => {
  assertHasProperties(['email', 'password'], value);
  assertIsString(value.email);
  assertIsString(value.password);

  // 1. The password should have at least 8 characters
  if (value.password.length < 8) throw Error('Password is too short');
};

Se você não sabe o que é uma função de asserção, fiz um vídeo inteiro sobre isso, é o quinto vídeo da série. Vou deixar o link para ele aqui nas referências.

Antes de adicionarmos nossa validação assíncrona para nos certificarmos de que o e-mail não pertence a outro usuário, vamos ver o que aconteceria se tentássemos usar nossa função de asserção do jeito que está agora.

TypeScript
const saveUserToDatabase =
  async (user: User): Promise<void> => { ... }

type ValidateUser =
  (value: unknown) =>
    asserts value is User
const validateUser: ValidateUser = value => { ... }

let user: unknown
validateUser(user)
await saveUserToDatabase(user)

Como você pode ver, o TypeScript acredita que está tudo bem, não há erros de compilação. A função saveUserToDatabase está esperando um usuário e é isso que está recebendo, mas ele não sabe que este usuário ainda não foi totalmente validado.

Na verdade, o TypeScript pensaria que está tudo bem, mesmo que não tivéssemos validado nem o comprimento da senha. Resumindo, o TypeScript está apenas verificando se o nosso valor concorda com a interface User. Não está verificando se passamos nos critérios de validação.

Poderíamos de alguma forma dizer ao TypeScript quando um valor não está apenas em conformidade com a interface User, mas também é um User totalmente validado? Bom, sim! Podemos!

Podemos criar uma propriedade secreta para indicar que o comprimento da senha foi validado. E outro para indicar que o e-mail não pertence a outro usuário. Também podemos ter um union type chamado ValidatedUser, que é igual a um usuário que possui ambas as validações.

TypeScript
type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol;
};

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol;
};

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>;

Com isso pronto, podemos alterar a assinatura de validateUser, para afirmar que o valor não é apenas um User, mas um PasswordValidated<User> (usuário validado por senha).

TypeScript
type ValidateUser =
  (value: unknown) =>
    asserts value is User
const validateUser: ValidateUser = value => { ... }
TypeScript
type ValidateUser =
  (value: unknown) =>
    asserts value is PasswordValidated<User>
const validateUser: ValidateUser = value => { ... }

E também podemos alterar a assinatura de saveUserToDatabase, para aceitarmos apenas usuários que já foram totalmente validados.

TypeScript
type ValidateUser =
  (value: unknown) =>
    asserts value is PasswordValidated<User>
const validateUser: ValidateUser = value => { ... }

const saveUserToDatabase =
  async (validUser: ValidatedUser): Promise<void> => { ... }

Com essa estrutura em vigor, estou estranhamente feliz em dizer que nosso código não compila!

TypeScript
type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol
}

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol
}

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>

const validateUser =
  (value: User): asserts value is PasswordValidated<User> => { ... }

const saveUserToDatabase =
  async (validUser: ValidatedUser): Promise<void> => { ... }

let user: unknown
validateUser(user)
await saveUserToDatabase(user)
// Compilation error: Argument of type 'PasswordValidated<User>' is not assignable to parameter of type 'ValidatedUser'.

Aeee! 🎉🎉🎉

Para compilar novamente, precisamos criar uma função de asserção assíncrona usando o mesmo truque que usamos para type guards assíncronos.

TypeScript
type ValidateUser = (
  value: unknown
) => asserts value is PasswordValidated<User> & UniqueEmailValidated<User>;

const validateUserAsync = async (value: unknown): Promise<ValidateUser> => {
  // If we throw an error, save it to throw later, in the assertion function
  let errorToThrow: Error | null = null;

  try {
    assertHasProperties(['email', 'password'], value);
    assertIsString(value.email);
    assertIsString(value.password);

    // 1. The password should have at least 8 characters
    if (value.password.length < 8) throw Error('Password is too short');

    // 2. The email cannot already belong to another user
    if (await emailIsAlreadyTaken(value.email))
      throw Error('Email is already taken');
  } catch (error) {
    errorToThrow = error;
  }

  return (v) => {
    if (errorToThrow) throw errorToThrow;
  };
};

Ok, agora sim! Chamar validateUserAsync retorna a Promise de uma função de asserção síncrona, que usamos para afirmar que "value" é um usuário validado.

TypeScript
type PasswordValidated<T> = T & {
  readonly __passwordValidated__: unique symbol
}

type UniqueEmailValidated<T> = T & {
  readonly __uniqueEmailValidated__: unique symbol
}

type ValidatedUser = PasswordValidated<User> & UniqueEmailValidated<User>

type ValidateUser =
  (value: unknown) =>
    asserts value is
      PasswordValidated<User> & UniqueEmailValidated<User>
const validateUserAsync =
  async (value: unknown): Promise<ValidateUser> => { ... }

const saveUserToDatabase =
  async (validUser: ValidatedUser): Promise<void> => { ... }

let user: unknown
const validateUser: ValidateUser = await validateUserAsync(user)
validateUser(user)
await saveUserToDatabase(user)

Legal. Agora estamos nos protegendo de realizar operações no banco de dados com recursos não validados. Esse é um caso de uso real para type guards assíncronos, e um monte de loucura em TypeScript avançado.

makeAsyncPredicateFunction

E sabe de uma coisa? Eu amo você! Então, fui em frente e tornei nossas vidas ainda mais fáceis.

Eu fiz um higher order guard assíncrono que cria type guards assíncronos para nós. Se você escolher usar a minha função, será ainda mais fácil criar type guards assíncronos, e refatorá-los quando tivermos suporte nativo no TypeScript.

TypeScript
import { makeAsyncPredicateFunction } from '@lucaspaganini/ts';

const isStringAsync = makeAsyncPredicateFunction<string>(
  async (value) => typeof value === 'string'
);

const aaa = 1 as number | string | Date;
isStringAsync(aaa).then((isString) => {
  if (isString(aaa)) {
    aaa; // <- aaa: string
  } else {
    aaa; // <- aaa: number | Date
  }
});

Funciona em Node e navegadores. Você pode instalar com npm install @lucaspaganini/ts. Tenho muito mais a dizer sobre essa biblioteca, mas isso é assunto para o próximo vídeo. Então fique ligado.

LinkConclusão

As referências estão abaixo.

Fiz um comentário na solicitação de type guards assíncronos, explicando essa solução que eu encontrei. Seria ótimo, se você deixasse um like na solicitação de recurso e no meu comentário, para dar mais tração. O link disso também está nas referências.

Se você gostou do conteúdo, sabe o que fazer. E se a sua empresa está procurando desenvolvedores web remotos, considere entrar em contato comigo e com a minha equipe em lucaspaganini.com.

No próximo artigo vamos falar sobre aquela biblioteca, e estou planejando que seja o último vídeo desta série. Então fique ligado, eu acho que vai valer a pena.

Até então, tenha um ótimo dia, e nos vemos em breve!

LinkReferências

  1. Solicitação de feature - Type Guards Assíncronos e Assertion Signatures Repositório do TypeScript no GitHub
  2. Meu Comentário Explicando esta Solução Repositório do TypeScript no GitHub
  3. Biblioteca de utilitários TypeScript - @lucaspaganini/ts Repositório no GitHub

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