Como usar o Effect-TS para modelar erros e dependências em TypeScript

1. Introdução ao Effect-TS e seus conceitos fundamentais

Effect-TS é uma biblioteca funcional para TypeScript que oferece uma abordagem robusta para gerenciar efeitos colaterais, erros e dependências de forma tipada e composicional. Diferente de Promise, que trata qualquer rejeição como unknown, ou Try, que captura exceções genéricas, o tipo Effect<Success, Error, Requirements> permite declarar explicitamente o tipo de sucesso, o tipo de erro e as dependências necessárias para executar um efeito.

Para configurar um projeto com Effect-TS, instale o pacote principal:

npm install effect

Em seguida, importe os tipos e funções básicas:

import { Effect, pipe } from "effect";

O tipo Effect é a unidade central. Um efeito que lê um arquivo e pode falhar com um erro FileNotFound depende de um serviço FileSystem:

type FileNotFound = { readonly _tag: "FileNotFound"; readonly path: string };
type ReadFile = Effect<string, FileNotFound, FileSystem>;

2. Modelagem de erros com efeitos tipados

Erros previsíveis são declarados diretamente no tipo do efeito. Use Either para representar sucesso ou falha sem exceções, Option para valores opcionais e Cause para erros complexos com múltiplas causas.

Exemplo de validação de entrada:

type ValidationError = 
  | { readonly _tag: "EmptyString" }
  | { readonly _tag: "InvalidEmail" };

function validateEmail(input: string): Effect<string, ValidationError, never> {
  if (input.length === 0) return Effect.fail({ _tag: "EmptyString" });
  if (!input.includes("@")) return Effect.fail({ _tag: "InvalidEmail" });
  return Effect.succeed(input);
}

Tratamento de erros com catchAll, catchTag e orElse:

const program = pipe(
  validateEmail(""),
  Effect.catchTag("EmptyString", () => Effect.succeed("fallback@example.com")),
  Effect.catchAll((error) => Effect.succeed(`Erro tratado: ${error._tag}`))
);

catchTag permite tratar erros específicos por sua tag, enquanto catchAll captura qualquer erro restante.

3. Gerenciamento de dependências e injeção de serviços

O terceiro parâmetro genérico Requirements define as dependências do efeito. Crie serviços com Context.Tag e Layer:

import { Context, Layer } from "effect";

class DatabaseService extends Context.Tag("DatabaseService")<
  DatabaseService,
  { readonly query: (sql: string) => Effect<any, Error, never> }
>() {}

class LoggerService extends Context.Tag("LoggerService")<
  LoggerService,
  { readonly log: (message: string) => Effect<void, never, never> }
>() {}

Defina implementações concretas:

const DatabaseLive = Layer.succeed(DatabaseService, {
  query: (sql) => Effect.succeed(`Resultado de: ${sql}`)
});

const LoggerLive = Layer.succeed(LoggerService, {
  log: (message) => Effect.sync(() => console.log(message))
});

Componha camadas com Layer.mergeAll e forneça ao efeito principal:

const MainLayer = Layer.mergeAll(DatabaseLive, LoggerLive);

const app = Effect.flatMap(
  DatabaseService,
  (db) => db.query("SELECT * FROM users")
);

const runnable = Layer.provide(app, MainLayer);

4. Composição e encadeamento de efeitos

Operadores como map, flatMap, zip e forEach permitem compor efeitos de forma declarativa. Use pipe para encadear operações:

const fetchUser = (id: number): Effect<{ name: string }, Error, HttpClient> => 
  Effect.succeed({ name: "Alice" });

const saveLog = (msg: string): Effect<void, Error, LoggerService> =>
  Effect.flatMap(LoggerService, (logger) => logger.log(msg));

const workflow = pipe(
  fetchUser(1),
  Effect.flatMap((user) => saveLog(`Usuário ${user.name} carregado`)),
  Effect.map(() => "Processo concluído")
);

Exemplo prático: fluxo de validação + persistência com tratamento de erros:

const createUser = (data: { email: string }) =>
  pipe(
    validateEmail(data.email),
    Effect.flatMap((email) => 
      Effect.flatMap(DatabaseService, (db) => db.query(`INSERT INTO users (email) VALUES ('${email}')`))
    ),
    Effect.catchTag("EmptyString", () => Effect.fail({ _tag: "InvalidInput" })),
    Effect.catchAll((error) => 
      Effect.flatMap(LoggerService, (logger) => logger.log(`Falha: ${error._tag}`))
    )
  );

5. Testabilidade e simulação de dependências

Substitua serviços reais por TestLayer para testes isolados:

import { TestContext } from "effect/testing";

const DatabaseTest = Layer.succeed(DatabaseService, {
  query: (sql) => Effect.succeed(`Mock: ${sql}`)
});

const testLayer = Layer.mergeAll(DatabaseTest, LoggerLive);

Use Effect.runPromise para testes assíncronos e Effect.runSync para síncronos:

const testProgram = pipe(
  createUser({ email: "test@example.com" }),
  Layer.provide(testLayer),
  Effect.runPromise
);
// Resultado: "Mock: INSERT INTO users (email) VALUES ('test@example.com')"

Isole erros com Effect.either para inspecionar falhas sem propagação:

const result = Effect.runSync(
  pipe(
    validateEmail("invalido"),
    Effect.either
  )
);
// result é Either<ValidationError, string>

Effect.sandbox expõe a Cause completa para depuração avançada.

6. Padrões avançados: retry, timeout e concorrência

Políticas de retry com Schedule:

import { Schedule } from "effect";

const retryPolicy = pipe(
  Schedule.exponential("100 millis"),
  Schedule.compose(Schedule.recurs(3))
);

const withRetry = pipe(
  Effect.fail("temporary error"),
  Effect.retry(retryPolicy)
);

Timeout e cancelamento:

const timeoutEffect = pipe(
  Effect.sleep("5 seconds"),
  Effect.timeout("1 second"),
  Effect.catchAll(() => Effect.succeed("Timeout ocorreu"))
);

Execução concorrente segura:

const task1 = Effect.succeed("A");
const task2 = Effect.succeed("B");

const parallel = Effect.zipPar(task1, task2);
// Executa ambas simultaneamente e combina resultados

const items = [1, 2, 3];
const parallelForEach = Effect.forEachPar(items, (n) => Effect.succeed(n * 2));
// Processa todos em paralelo

7. Caso de uso completo: API REST com dependências e erros modelados

Definição de serviços:

class HttpClient extends Context.Tag("HttpClient")<
  HttpClient,
  { readonly get: (url: string) => Effect<string, Error, never> }
>() {}

class Database extends Context.Tag("Database")<
  Database,
  { readonly findUser: (id: number) => Effect<{ name: string } | null, Error, never> }
>() {}

class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly info: (msg: string) => Effect<void, never, never> }
>() {}

Endpoint que lida com erros de domínio e infraestrutura:

type ApiError = 
  | { readonly _tag: "UserNotFound" }
  | { readonly _tag: "ExternalServiceError" }
  | { readonly _tag: "DatabaseError" };

const getUserProfile = (userId: number): Effect<string, ApiError, HttpClient | Database | Logger> =>
  pipe(
    Effect.flatMap(Database, (db) => db.findUser(userId)),
    Effect.catchAll(() => Effect.fail({ _tag: "DatabaseError" })),
    Effect.flatMap((user) => 
      user 
        ? Effect.succeed(user) 
        : Effect.fail({ _tag: "UserNotFound" })
    ),
    Effect.flatMap((user) =>
      pipe(
        Effect.flatMap(HttpClient, (http) => http.get(`https://api.example.com/users/${user.name}`)),
        Effect.catchAll(() => Effect.fail({ _tag: "ExternalServiceError" }))
      )
    ),
    Effect.tap((response) =>
      Effect.flatMap(Logger, (logger) => logger.info(`Perfil carregado: ${response}`))
    )
  );

Montagem final:

const HttpLive = Layer.succeed(HttpClient, { get: (url) => Effect.succeed(`dados de ${url}`) });
const DbLive = Layer.succeed(Database, { findUser: (id) => Effect.succeed({ name: "Alice" }) });
const LoggerLive = Layer.succeed(Logger, { info: (msg) => Effect.sync(() => console.log(msg)) });

const AppLayer = Layer.mergeAll(HttpLive, DbLive, LoggerLive);

const app = pipe(
  getUserProfile(1),
  Layer.provide(AppLayer),
  Effect.runPromise
);
// Log: "Perfil carregado: dados de https://api.example.com/users/Alice"

Effect-TS transforma a modelagem de erros e dependências em uma experiência tipada, previsível e testável, eliminando surpresas em tempo de execução e promovendo código mais seguro e expressivo.

Referências