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:
- as Services que o código utiliza;
- as implementações que os Layers fornecem;
- 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.