DEV Community

Cover image for Praticando magia azul com Promises e JavaScript
Gustavo Santos
Gustavo Santos

Posted on

Praticando magia azul com Promises e JavaScript

Talvez em algum momento da sua vida, você possa ter se deparado com um problema onde é necessário fazer uma consulta via REST em algum endpoint, usar o resultado dessa requisição em caso de sucesso, e então fazer outra requisição REST em algum outro endpoint e então retornar algum resultado em caso de sucesso, ou talvez retornar um erro em caso de falha, ou disparar uma exceção, ou retornar false ou até retornar nada.

Eu sei, é bastante problemático lidar com operações assíncronas, eu preciso lidar com isso todos os dias. As vezes a gente faz um trabalho legal em gerenciar tudo, as vezes a gente faz um trabalho não tão bom e engloba todas as chamadas assíncronas dentro de um block try/catch e vida que segue. Porém, a bagunça fica grande muito rápido.

Nesse texto, que espero não ficar longo, vou tentar expor os meus dois centavos no assunto de gerência de operações assíncronas com JavaScript, seguindo a abordagem funcional/declarativa.


Promises

Promises são abstrações temporais. São mecanismos de gerência de informações que eventualmente irão existir. São uma caixa que pode, no futuro, conter algum valor.

A parte mais incrível de Promises é que você consegue compor esse tipo de informação através do método then. Por exemplo:

const result = Promise.resolve(1)
    .then(x => x + 1)
    .then(x => x * 3)
    .then(x => x ** 2);

result; // Promise { 36 }
Enter fullscreen mode Exit fullscreen mode

E em qualquer momento durante a cadeia de chamadas ao método then, você pode declarar um callback a ser executado em caso de erro, registrando através do método catch:

const result = Promise.resolve(1)
    .then(x => x + 1)
    .then(x => x * 3)
    .then(x => { throw new Error() })
    .then(x => x ** 2)
    .catch(() => 'oops');

result; // Promise { "oops" }
Enter fullscreen mode Exit fullscreen mode

Mas isso você provavelmente já sabia, então vamos direto ao assunto funcional.


A pureza das funções

Uma função pura é uma função que não extrapola o contexto da sua execução, ou seja, é uma função que para uma entrada do tipo A, uma saída do tipo B sempre existirá e não haverá qualquer tipo de interação com o contexto de fora da função.

Um exemplo de função que não é uma função pura:

const log = (what) => console.log('logging:', what);
Enter fullscreen mode Exit fullscreen mode

O objeto console não faz parte da definição do ECMAScript, além disso, essa função interage com um contexto exterior à função log, escrevendo um texto na saída padrão. A saída padrão é um ambiente hostil, um ambiente que pode falhar, por esses motivos, a função log é uma ação de efeito colateral, onde o efeito colateral é a escrita de um texto no Console.

Ok, mas o que é uma função pura? Esse tópico merece um artigo próprio, mas uma função pura possui seu domínio e contra-domínio bem definidos. Uma função pura não extrapola seu escopo. Uma função pura possui uma saída para qualquer tipo de entrada. Um exemplo de função pura é a função add abaixo:

const add = (x, y) => x + y;
Enter fullscreen mode Exit fullscreen mode

Uma função pura é determinística. Para quaisquer números x e y, existirá um número z que é a soma de x e y. Uma função pura possui tempo de execução constante e determinístico, o runtime sempre executará as mesmas instruções, o uso de memória sempre será o mesmo. Funções puras não acessam o console, não acessam o window, não acessam o espaço de nomes de um módulo. Para um dado valor de entrada, uma função pura sempre possuirá um valor de retorno determinístico.


Programação com efeitos

Efeitos são ações executadas dentro ou fora do contexto do programa que interagem com o sistema que executa o programa, ou seja, um efeito é qualquer tipo de operação de IO. Por exemplo, podemos criar uma ação de efeito colateral ao tentar ler um arquivo do nosso diretório atual no Node.js:

const fs = require('fs');

const readFile = (file) =>
    fs.readFileSync(file, { encoding: 'utf8' });

const file = readFile('.gitignore');
file; // "/node_modules/\n/dist/\n ..."
Enter fullscreen mode Exit fullscreen mode

Se você executar este trecho de código em algum diretório que não contenha o arquivo “.gitignore”, o Node vai disparar uma exceção de arquivo não encontrado e, ao menos que você trate esta exceção, seu programa vai abortar a execução.

Além de acessar um arquivo no diretório atual, é também considerado um efeito colateral a ação de escrever algo na saída padrão, como no caso da função console.log. Qualquer ação que extrapole o contexto da execução de uma função, pode ser considerado um efeito colateral. Por exemplo, o acesso a variáveis globais, variáveis de ambientes, e assim por diante.


Levantamento

O conceito de levantamento significa que, a partir de um pedaço de informação em um determinado contexto, ao levantar esta informação a outro contexto, serão adicionadas funcionalidades do novo contexto ao contexto anterior. Por exemplo, podemos dizer que vamos levantar um valor ao contexto de array fazendo a seguinte operação:

const valor = 2;
const valorArr = [valor];
Enter fullscreen mode Exit fullscreen mode

Consequentemente, adicionamos ao contexto do valor 2, informações do contexto de arrays. Agora podemos aplicar os métodos map, filter, reduce, sort e vários outros presentes no contexto de arrays:

const toArray = (value) => [value];

const value = 2;
const squareValue = toArray(value)
    .map((x) => x * x)

console.log(squareValue); // [4]
Enter fullscreen mode Exit fullscreen mode

No contexto de Promises, podemos fazer o mesmo. A questão é que precisamos abrir nossa mente e pensar de forma a criar uma abstração temporal para um determinado valor.

Vamos definir a função pure, essa função recebe um valor e retorna uma abstração temporal a este valor. Em outras palavras, a função pure recebe um valor e retorna uma Promise que resolve para este valor:

const pure = (value) => Promise.resolve(value)
Enter fullscreen mode Exit fullscreen mode

Agora conseguimos aplicar os métodos then, catch e finally em qualquer valor:

pure(2)
    .then(x => x * x)
    .then(x => x - 1)
    .then(console.log)
Enter fullscreen mode Exit fullscreen mode

Em termos formais, a função pure tem a assinatura: pure :: a → f a, onde o tipo a é convertido em uma estrutura do tipo f a. No nosso caso neste texto, a função pure tem a assinatura pure :: any → Promise<any>. Assim, para qualquer valor, pure levanta este valor para um contexto de Promise.


Sob a ótica da abstração

Conceitos funcionais abstraem padrões que acontecem no código. Na natureza existem diversos padrões que, volta e meia nós, humanos, abraçamos estes padrões e criamos coisas parecidas, as vezes criamos coisas tão melhores que transcendemos os padrões da natureza.

Podemos pensar no contexto de braços. Braços aparecem na natureza em diversas formas e modelos, alguns são mais fortes e longos, outros são mais fracos e curtos. Alguns parecem com pernas, outros são de fato pernas, e até existem alguns que são usados para formar um sistema de voo.

Nós, homo sapiens, também fazemos braços, alguns são desajeitados e baratos, outros são grandes, fortes, precisos e caros. Mas no final das contas, são todos criações e abstrações para um conjunto de objetivos.

No código conseguimos criar abstrações também. Algumas abstrações são tão bem definidas que são conhecidos como design patterns, que são formas muito bem definidas de criar abstrações para resolver problemas.

Programas funcionais, ou, declarativos, também se apoiam sobre abstrações, alguns exemplos são os Functores, Aplicativos, Monadas e diversos outros. Mas no final das contas, são todos abstrações. Alguns são mais eficientes que outros em um determinado contexto. Se nós usarmos os padrões de Aplicativos em programas que são estruturados de forma a seguir diretrizes da Orientação a Objetos, o código simplesmente não vai encaixar bem.

Imagine o problema: é necessário fazer uma busca de usuário baseado em um identificador, em seguida, precisamos descobrir se este usuário é administrador. Existem duas funções, cada função é responsável de uma coisa. A função getUserById recebe um identificador e, em caso de sucesso, retorna uma Promise que resolve para um objeto contendo informações de um usuário, caso contrário retorna uma Promise que resolve para um erro.

A outra função, isUserAdmin, recebe um objeto que representa um usuário e baseado neste objeto, decide se o usuário é admin.

Podemos, então escrever um código parecido com isso:

async function run() {
    const userId = 2;
    const user = await getUserById(userId);

    if (user instanceof Error) {
        console.log(user.message);
        return;
    }

    const isAdmin = await isUserAdmin(user);
    if (isAdmin) {
        console.log('The user is admin!');
    }
}
run();
Enter fullscreen mode Exit fullscreen mode

Ou podemos reescrever este mesmo código usando abstrações:

async function run() {
    const userId = 1;

    const isAdmin = await bind(
        () => pure(userId),
        getUserById,
        isUserAdmin,
    );

    if (isAdmin) {
        console.log('The user is admin!');
    }
}
run();
Enter fullscreen mode Exit fullscreen mode

Levantando funções

Usando o conceito de levantamento, podemos extender para operações mais complexas. Você pode levantar qualquer coisa ao contexto de Promise. Por exemplo, você pode levantar uma função pura ao contexto de Promise e aplicar esta função em um ambiente impuro.

Por exemplo, imagine que você tem uma função que adiciona 1 a um determinado número, por exemplo:

    const plusOne = (n) => n + 1
Enter fullscreen mode Exit fullscreen mode

Para qualquer número n, plusOne retorna este número mais um.

Agora imagine algum ambiente exótico onde você precisa aplicar esta função em um número futuro. Em algum momento do tempo, vai existir um número n e você precisa somar um a esse número assim que ele existir.

Podemos nos apoiar em conceitos funcionais e usar a função lift, que recebe uma função pura e uma função do tipo factory, que retorna uma Promise:

const plusOne = (n) => n + 1;

const getNumber = () =>
    Promise.resolve(Math.floor(Math.random() * 10))

async function run() {
    const result = await lift(plusOne, getNumber);
    console.log(result); // 1, 4, 9, 2, ...
}
run();
Enter fullscreen mode Exit fullscreen mode

A função getNumber é impura. O retorno de getNumber é inconsistente, sua imagem não é mapeável a seu domínio, a imagem de getNumber muda a cada invocação, não é uma função consistente em resultado, tempo de execução e uso de memória. Porém a função plusOne é uma função pura. Funções puras e impuras não se relacionam, a menos que usemos abstrações para relacionar estas funções.

No código anterior, levantamos a função plusOne ao contexto impuro da função getNumber. Uma vez levantada uma função pura a um contexto impuro, o fluxo da computação jamais se tornará puro novamente. Não é possível extrair um valor de dentro do contexto de Promise em um contexto puro. Uma vez posto um valor dentro de uma Promise, é impossível remover o valor de dentro da Promise.

Conseguimos operar com valores de Promises dentro de um contexto assíncrono criado a partir do uso da palavra chave async. Quando criamos funções assíncronas, estamos fazendo um contrato com o runtime do JavaScript de que todas as operações que iremos executar serão impuras, passíveis de erros, não determinísticas. Por esse motivo, é considerado uma boa prática englobar qualquer chamada a uma função usando a palavra chave await em um bloco try/catch.


Controle declarativo

Controlar a execução assíncrona do JavaScript é um desafio a parte. Diariamente lidamos com fluxos de execução assíncronos no JavaScript de forma manual, é natural para qualquer pessoa que desenvolve programas lidar com fluxos assíncronos e funções impuras, pelo simples motivo que programas que não possuem efeitos colaterais, não são úteis.

Jamais conseguiríamos fazer um programa que calcula a soma de dois números se não aceitarmos valores do mundo externo, seja via argumentos da linha de comando ou inputs em alguma interface bonitinha. Também nunca poderíamos mostrar o resultado se não tivermos acesso a saída padrão ou a algum elemento no DOM.

Porém podemos nos apoiar em construções de controle declarativas para, no pior das hipóteses, simplificar a nossa base de código, construindo programas baseado em blocos de construção mais simples, combináveis, fáceis de testar e difíceis de conter erros.

Uma das coisas que mais me chamam a atenção em Haskell é a forma de, dependendo da abstração que usamos, a própria abstração lida com possível problemas que podem ocorrer em tempo de execução. O maquinário de Aplicativos, por exemplo, consegue lidar com a ausência de valores, com computações que podem falhar e diversos outros fatores de programação com efeitos colaterais.

O maquinário de Monadas é mais completo e complexo, lidando não só com problemas que Aplicativos lidam, como acessos a mundo externos, composição de mundos puros e impuros e outras combinações.

Baseado nisso imagine três funções: liftP2, flatMap, bind e exec. Cada uma dessas funções abstraem problemas de controle assíncrono de formas diferentes.

A função liftP2 levanta uma função pura a um contexto impuro e espera por outros dois efeitos colaterais serem resolvidos, então aplica essa função pura aos resultados destes efeitos colaterais e retorna uma Promise contendo o resultado, por exemplo:

const getUser = () => Promise.resolve({ name: 'User', role: 2 })
const getAdminRoles = () => Promise.resolve([1, 2, 3]);
const userHasReadRole = (user, roles) => roles.includes(user.role);

async function main() {
    const canRead = await liftP2(userHasReadRole, getUser, getAdminRoles);
    if (canRead) {
        console.log('user can read this')
        return
    }
    console.log('access deny')
}
main();
Enter fullscreen mode Exit fullscreen mode

Já se você quer aplicar alguma função pura a uma Promise, você pode usar a função flatMap que resolve a Promise, aplica o valor resolvido da Promise na função pura e retorna uma Promise contendo o resultado final:

const getUser = () => Promise.resolve({ name: 'User', role: 2 })
const viewName = (user) => user.name;

async function main() {
    const name = await flatMap(getUser, viewName);
    console.log(name);
}
main();
Enter fullscreen mode Exit fullscreen mode

A função bind recebe uma função factory que retorna uma Promise, e uma lista de funções assíncronas que serão aplicadas sequencialmente. Baseado em uma factory inicial, a função bind vai resolver a primeira Promise e aplicar o resultado na primeira função impura, então vai esperar esta função ser resolvida e o resultado será aplicado na segunda função impura e assim por diante.

const initial = Promise.resolve(1);
const doubleP = (x) => Promise.resolve(x * 2);
const tripleP = (x) => Promise.resolve(x * 3);
const stringifyP = (x) => Promise.resolve(String(x));

bind(initial, doubleP, tripleP, stringifyP);
// Promise { '6' }
Enter fullscreen mode Exit fullscreen mode

Se em algum momento durante a execução alguma das funções impuras emitir uma exceção ou retornar um erro, o fluxo de execução será abortado e uma Promise que resolve para uma instância de erro será retornada. Caso o fluxo de ações chegue ao fim com sucesso, uma Promise que resolve para o retorno da última função impura será retornada.

Nem sempre precisamos construir um pipeline de funções dependentes umas das outras. Se você precisa executar uma sequência de ações, descartando qualquer resultado intermediário gerado, você pode compor a execução dessa forma:

const doSomethingAsync = () => ...
const doOtherThingAfterFirstAsync = () => ...

exec(doSomethingAsync, doOtherThingAfterFirstAsync)
Enter fullscreen mode Exit fullscreen mode

A função exec vai executar as funções em ordem, descartando qualquer resultado intermediário. Em caso de qualquer erro, a execução é abortada e o resultado é resolvido para uma instância de erro.


Conclusões

Independente do seu background profissional, se você chegou até aqui significa que, além de força de vontade, você tem, no mínimo, muita curiosidade em como escrever programas de forma declarativa, mais fáceis de dar manutenção, mais fáceis de entender, mais fáceis de testar e menos suscetíveis a problemas.

Além dessa leitura, eu recomendo outros blogs e livros sobre programação funcional:


Tsoid

Todas as funções de gerência de operações assíncronas que eu mostrei nesse artigo estão definidas no pacote Tsoid disponível no NPM. O pacote Tsoid define funções transientes, ou seja, estas funções oferecem uma interface imutável ao mesmo tempo que usam construções mutáveis internamente.

O conceito de funções transientes é explorado por outras linguagens, principalmente por Clojure. Funções de auxílio de controle como sequence, bind, map, filter, reduce, flatMap, lift e várias outras estão definidas e disponíveis para o uso. Nenhuma função de controle do pacote Tsoid emite exceções, este padrão segue a minha opinião de não trabalhar com exceções. Portanto, todas funções de controle retornam uma Promise que resolve para o resultado da operação ou para uma instância de erro.

Além da Tsoid, existem outras bibliotecas que auxiliam o programador JavaScript a escrever códigos declarativos, dentre elas eu gosto de destacar:

Top comments (0)