A descrição e a execução
A frase-mãe do Effect já ficou enunciada na página anterior: separar a descrição da execução. O problema é que, sozinha, ela soa como slogan. Falta ver o que ela muda na prática, e o melhor ponto de partida é o jeito de programar que ela substitui.
O código comum executa enquanto é lido. Quando o interpretador chega numa linha como a de baixo, ele acessa o banco naquele instante e envia o e-mail logo em seguida:
const user = await db.findUser(id)await email.send(user)No Effect, a mesma lógica produz outra coisa. Você descreve os passos, mas nada dispara:
const program = Effect.gen(function* () { const user = yield* db.findUser(id) yield* email.send(user)})Aqui não houve acesso ao banco nem envio de e-mail. Você construiu um valor chamado program, que guarda a intenção de fazer essas coisas. O banco só é tocado quando alguém entrega esse valor ao runtime:
Effect.runPromise(program)Essa é a distinção que sustenta a biblioteca inteira: descrever um programa é diferente de executá-lo, e o Effect torna a descrição um valor de primeira classe que você pode guardar, passar adiante e transformar antes que qualquer efeito aconteça.
O laziness é consequência, não o objetivo
Section titled “O laziness é consequência, não o objetivo”É comum resumir o Effect como “uma forma lazy de programar”, mas isso inverte causa e efeito. A biblioteca não foi desenhada para adiar execução como um fim em si. Ela foi desenhada para separar a descrição da execução, e o adiamento cai fora disso de graça: se o programa é um valor, ele naturalmente não roda enquanto você não pedir. Dizer “o Effect é lazy” descreve o sintoma; “o Effect separa descrição de execução” descreve a intenção, e é dela que todo o resto decorre.
O que você ganha ao adiar
Section titled “O que você ganha ao adiar”O valor de ter o programa como dado, antes de rodá-lo, é que você pode operar sobre ele. Sobre uma descrição você acrescenta retry, timeout, log, tracing; troca as dependências; roda em paralelo; cancela. E o ponto que costuma passar despercebido é que nenhuma dessas operações executa nada:
Effect.retry(program)Effect.timeout(program, "5 seconds")Effect.withSpan(program, "load-user")Effect.provide(program, DatabaseLive)Cada uma dessas chamadas recebe uma descrição e devolve outra, enriquecida com um comportamento novo, ainda sem tocar no mundo. A analogia mais próxima é a de um Array, que também é um valor imutável sobre o qual você deriva outros valores:
const xs = [1, 2, 3]const ys = xs.map((x) => x * 2)const zs = ys.filter((x) => x > 2)Nenhum map ou filter altera o array original; cada um deriva um novo a partir do anterior, e a cadeia só “vira valor” quando você a percorre. Com Effect é o mesmo gênero de operação, e é o que permite tratar retry, timeout, observabilidade e injeção de dependências como camadas aplicadas por fora, em vez de código costurado no meio da lógica de negócio. A lógica descreve o quê; as transformações dizem como aquilo será executado.
Uma árvore, não uma cebola
Section titled “Uma árvore, não uma cebola”Se cada operação produz uma nova descrição a partir de outras, o programa inteiro acaba sendo uma estrutura composta: um Effect feito de Effects menores. A tentação é imaginar isso como um envelopamento em cebola, uma caixa dentro da outra:
Effect( Effect( Effect( ... ) ) )Essa imagem engana. O mais fiel é pensar num grupo que contém vários subgrupos, cada um podendo conter outros, formando uma árvore (ou, quando há reuso e ramificação, um grafo) de operações:
flowchart TD P["Programa · Effect"] P --> A["validar entrada"] P --> B["buscar usuário"] P --> C["parsear resposta"] P --> D["enviar e-mail"] B --> B1(["sql.query(...)"]) C --> C1(["JSON.parse(...)"]) D --> D1(["fetch(...)"])
Cada chamada de composição (flatMap, map, zip, retry, catch, timeout) cria um nó nessa árvore. No topo existe um único Effect raiz que representa a aplicação toda, e é essa estrutura que o runtime percorre quando você executa. É a mesma família de ideia por trás de outras ferramentas: o React monta uma árvore de componentes, o SQL monta uma consulta, o Effect monta um programa. Em todos, você descreve a estrutura primeiro e um interpretador a executa depois.
Por isso transformar o programa não mexe em nada que “já rodou”. Quando você escreve o encadeamento abaixo, está reescrevendo a árvore antes de ela ser interpretada, não alterando um efeito em andamento:
program.pipe( Effect.retry(policy), Effect.timeout("10 seconds"), Effect.withSpan("load-user"),)Nós de composição e nós de fronteira
Section titled “Nós de composição e nós de fronteira”Olhando a árvore, os nós se dividem em dois tipos, e essa divisão é um bom modelo mental para toda a biblioteca. Há os nós de composição, que só organizam e transformam outros Effects: Effect.gen, flatMap, map, zip, catch, retry, timeout. Eles nunca falam com o mundo; apenas dizem em que ordem as coisas acontecem, como tratar falhas, o que roda em paralelo. E há os nós de fronteira, as folhas da árvore, onde o Effect finalmente encosta em código nativo do JavaScript ou do ambiente. É onde vivem o sql.query, o fetch, o JSON.parse, e são introduzidos por um punhado de construtores: Effect.sync, Effect.try, Effect.promise, Effect.tryPromise.
As folhas, por sua vez, não são todas iguais. Uma parte delas é de código puro que pode lançar, como um JSON.parse ou um parser de datas: não interage com o mundo, mas pode estourar uma exceção. Outra parte é de efeito colateral de verdade, como sql.query, fetch, fs.readFile, console.log, ou mesmo Date.now() e Math.random(), que do ponto de vista funcional também são efeitos porque devolvem algo diferente a cada chamada. A escolha do construtor acompanha essa natureza, e é o que separa Effect.sync de Effect.try, assunto retomado em a borda e os erros.
Um Effect.gen, então, nunca chama JSON.parse diretamente. Ele orquestra folhas que fazem isso:
const parseJson = (text: string) => Effect.try({ try: () => JSON.parse(text), catch: (e) => new ParseError({ e }) })
const program = Effect.gen(function* () { const text = yield* readFile(path) const json = yield* parseJson(text) // yield* de outra folha, não JSON.parse solto return yield* findUser(json.id)})O gen só faz yield* de outros Effects. O JSON.parse fica encapsulado na folha parseJson, que descreve como executá-lo quando chegar a hora.
O contágio
Section titled “O contágio”Uma consequência direta de tudo isso é que o Effect “contamina” o código que o usa, e essa é uma das primeiras coisas que se nota ao adotá-lo. Se uma função retorna um Effect, quem a chama não recebe o valor pronto; recebe outra descrição:
function loadUser(id: string): Effect<User, DbError, Database> { /* ... */ }Quem chama loadUser não tem um User em mãos, tem um Effect<User, ...>. Em vez de desembrulhar ali, o normal é continuar compondo, e assim a função que chama também tende a devolver um Effect, e o mesmo acontece um nível acima. O efeito sobe pela aplicação inteira até a borda.
Esse contágio é intencional, não um incômodo a contornar. É ele que faz os erros, as dependências, o cancelamento e o contexto acompanharem o programa por toda a composição, sem se perderem no caminho. Você só desembrulha o Effect no limite da aplicação (o main, um handler HTTP, um worker, um job agendado), onde chama Effect.runPromise e entrega toda a descrição acumulada ao runtime. Até esse ponto, tudo é composição de descrições; a execução vem depois, e é o tema de a borda e os erros.
Palavras-chave
Section titled “Palavras-chave”Conceitos: descrição vs. execução, valor imutável, laziness como consequência, árvore de composição, grafo de operações, contágio, imutabilidade
Nós: nó de composição, nó de fronteira, folha, constructor, combinator
Construtores de folha: Effect.sync, Effect.try, Effect.promise, Effect.tryPromise
Combinadores: flatMap, map, zip, retry, timeout, Effect.provide, Effect.withSpan
Analogias: AST, árvore do React, consulta SQL, Array.map/filter