1

E se dependências ausentes fossem um erro de TypeScript, não de runtime?

Tipar os erros resolveu uma parte do problema. Mas dependências ainda podiam quebrar a aplicação.

Eu já estava satisfeito com uma parte importante do código das minhas aplicações: o tratamento de erros.

Em vez de uma função retornar um valor e lançar exceções que ninguém via no tipo, eu podia usar Result para deixar explícito o que acontecia quando a operação dava certo e quais falhas faziam parte daquele fluxo.

Com better-result, também era possível compor essas operações usando generators sem transformar o código em uma sequência difícil de acompanhar.

Mas ainda existia outro problema.

A aplicação podia compilar e falhar ao iniciar porque uma dependência não tinha sido registrada.

Os erros estavam tipados.

A montagem da aplicação não.

Foi dessa diferença que nasceu o better-effect.

O que um Result ainda não conseguia responder?

Considere um método com um retorno parecido com este:

Result<User, UserNotFound | DatabaseFailure>

Esse tipo já diz bastante coisa.

Ele mostra o valor de sucesso e as falhas esperadas.

Mas não responde:

  • De onde vem o banco de dados?
  • Quais Services esse método utiliza?
  • Todas essas Services foram fornecidas?
  • Esse app pode executar no ambiente atual?
  • Quem deve fechar a conexão?
  • Por quanto tempo esse recurso deve permanecer vivo?

Normalmente essas respostas ficam em constructors, módulos, containers de DI, arquivos de bootstrap ou configurações de teste.

O código mostra o que a operação faz.

Outra parte da aplicação tenta manter uma lista do que ela precisa.

Quando essas duas partes deixam de estar sincronizadas, o problema aparece quando você roda o app.

A dependência nasce do código que a utiliza

No better-effect, uma Service pode ser acessada dentro de Effect.gen:

import { Result } from "better-result"
import { Effect, Service } from "better-effect"

class Database extends Service<Database>() {
  findUser(id: string) {
    // ...
  }
}

class UserRepository extends Service<UserRepository>() {
  findUser(id: string) {
    return Effect.gen(async function* () {
      const database = yield* Database

      return Result.ok(await database.findUser(id))
    })
  }
}

A parte visível é esta:

const database = yield* Database

Em runtime, isso resolve Database no ambiente atual.

No typecheck, Database passa a fazer parte dos requirements daquele Effect.

Como UserRepository utiliza Database, essa dependência pode ser carregada pelo tipo do próprio UserRepository.

Não é necessário manter uma lista separada como:

dependencies: [Database]

O código que usa a dependência já é a fonte dessa informação.

Um ambiente incompleto não deveria precisar subir

Agora podemos criar um Layer para UserRepository:

const UserRepositoryLive = Layer.make(
  UserRepository,
  () => new UserRepository()
)

Esse Layer fornece UserRepository.

O problema é que UserRepository precisa de Database, e ainda não existe nenhuma implementação de Database configurada e fornecida.

Ao tentar criar o Runtime, o próprio TypeScript encontra o problema:

await Runtime.make(UserRepositoryLive, backend)
//                 ^^^^^^^^^^^^^^^^^^
// Database is required but not provided

O servidor não precisou iniciar.

O container não precisou lançar uma exceção.

Nenhuma requisição precisou chegar até aquele código.

A montagem estava incompleta e, por isso, o typecheck falhou.

Ao fornecer o banco, o ambiente fica completo:

const DatabaseLive = Layer.make(
  Database,
  () => new Database()
)

const AppLive = Layer.merge(
  DatabaseLive,
  UserRepositoryLive
)

const runtime = await Runtime.make(AppLive, backend)

Typechecked wiring

Eu tenho chamado essa relação de typechecked wiring.

É o contrato entre três partes:

  1. as Services que o código utiliza;
  2. as implementações que os Layers fornecem;
  3. os programas que um Runtime consegue executar.
flowchart TB
    A["yield* Database"]
    B["O programa precisa de Database"]
    C{"O Layer fornece Database?"}
    D["❌ Erro do TypeScript"]
    E["✅ O Runtime pode executar"]

    A --> B
    B --> C
    C -- "não" --> D
    C -- "sim" --> E

A informação não desaparece depois que o Runtime é criado.

Ele continua conhecendo o ambiente disponível:

await runtime.run(() =>
  Effect.gen(async function* () {
    const database = yield* Database

    return Result.ok(database)
  })
)

Se esse programa pedir uma Service que não existe naquele Runtime, o TypeScript rejeita a execução.

Isso pode ser útil em aplicações que possuem ambientes diferentes.

Um servidor HTTP, um worker, um script de migração e uma suíte de testes não precisam ter exatamente as mesmas dependências.

O tipo do Runtime deixa essa diferença visível.

Trocando implementações nos testes

Layers também podem ser sobrescritos:

const AppTest = Layer.override(
  AppLive,
  DatabaseTest
)

A implementação muda, mas o ambiente não perde seus tipos.

Isso permite montar ambientes de produção e teste sem depender apenas de mutações globais ou de um container montado manualmente em cada teste.

Algumas dependências também possuem um tempo de vida

Nem toda dependência é apenas um objeto que pode ficar na memória para sempre.

Algumas representam recursos:

  • conexões;
  • transações;
  • arquivos;
  • sockets;
  • sessões;
  • diretórios temporários.

Além de adquirir o recurso, a aplicação precisa saber quem é responsável por liberá-lo.

Uma conexão criada por um Layer pode pertencer ao Runtime:

const DatabaseLive = Layer.scoped(
  Database,
  () => Database.connect(),
  (database) => database.close()
)

Ela continua disponível enquanto o Runtime estiver ativo e é encerrada durante o shutdown.

Já um recurso adquirido dentro de uma execução pertence apenas àquela operação:

const connection = yield* Effect.acquireRelease(
  () => pool.reserve(),
  (connection, outcome) => connection.release(outcome)
)

Quando a execução termina, o recurso é liberado.

O cleanup também pode receber o resultado final da operação, o que permite casos como commit em sucesso e rollback em falha.

Por que não usar Effect diretamente?

Effect já oferece soluções muito mais completas para dependências, ambientes, recursos, concorrência e vários outros problemas.

Para muitos projetos, usar Effect diretamente será a melhor decisão.

Mas Effect também traz um runtime e um modelo de programação mais amplo, com fibers, concorrência estruturada, streams, schedules, queues e um ecossistema próprio.

A pergunta do better-effect é um pouco diferente:

E se eu quiser algumas dessas garantias arquiteturais, mas quiser continuar usando better-result, Promises e as bibliotecas que a aplicação já utiliza?

Essa escolha define os limites dessa biblioteca.

O better-effect não possui:

  • fibers;
  • scheduler próprio;
  • streams;
  • queues;
  • um tipo público Effect<A, E, R>;
  • um container completo de DI;
  • um novo modelo de tratamento de erros.

Effect.gen continua usando better-result para composição e propagação dos erros.

A resolução e o cache das Services ficam atrás de backends externos.

A biblioteca cuida principalmente do contrato entre Services, Layers, Runtime e Scope.

Onde o better-effect pode fazer sentido?

O público mais natural são desenvolvedores TypeScript que já gostam de Result types, mas chegaram ao ponto em que tratamento de erros deixou de ser o único problema arquitetural.

Pode fazer sentido em projetos que possuem:

  • Services e repositories;
  • um composition root explícito;
  • ambientes de produção e teste;
  • Clean Architecture ou DDD;
  • recursos com cleanup;
  • um container de DI que não precisa ser substituído.

Quando apenas Result e composição com generators forem suficientes, better-result pode ser utilizado sozinho.

Quando a aplicação quiser um effect system completo, Effect provavelmente será a escolha mais adequada.

O better-effect tenta explorar um espaço menor entre essas duas opções.

O projeto está aberto

O better-effect é open source.

A instalação pode ser feita com:

npm install better-effect better-result

O projeto ainda está no começo e quero manter API bem enxuta.

Neste momento, tenho mais interesse em validar o modelo do que em adicionar uma lista enorme de funcionalidades.

Algumas perguntas que ainda estou tentando responder:

  • A API de Service parece natural?
  • Os erros de ambiente incompleto são claros?
  • “Typechecked wiring” explica bem o benefício?
  • Quais responsabilidades deveriam continuar fora da biblioteca?
  • Esse modelo resolveria um problema real em aplicações TypeScript que você mantém?

Tipar os erros já ajuda bastante a entender o comportamento da aplicação.

A ideia do better-effect é fazer com que dependências, ambientes e lifetimes também deixem de ficar escondidos.

Repositório

Pacote NPM

Carregando publicação patrocinada...