Typed Errors Weren't Enough, So I Built better-effect
I had already moved an important part of my application code away from exceptions and into Result types.
That felt like a real improvement.
A function no longer returned User while quietly throwing five different exceptions. Its return type could describe both the successful value and the failures callers were expected to handle.
With better-result, I could also compose those operations using generators without losing the error channel.
But the application could still compile and fail at startup because I forgot to provide a dependency.
The errors were typed.
The application wiring wasn't.
That gap became the starting point for better-effect.
Result describes failure, but not the whole environment
Imagine a method with this return type:
Result<User, UserNotFound | DatabaseFailure>
It tells us quite a lot.
We know what success looks like. We know which failures belong to the operation. We can make callers handle those failures without relying on documentation or hidden exceptions.
But the type still doesn't answer a few other questions:
- Where does the database come from?
- Which Services does the operation need?
- Did the application provide all of them?
- Can this operation run in the current environment?
- Who owns the connection?
- When should that connection be released?
Those questions usually move somewhere else.
They may live in constructor parameters, framework modules, a dependency injection container, a bootstrap file or test setup.
That separation is normal, but it also means the code describing what an operation does and the code describing what it needs can easily fall out of sync.
Let the code reveal its own requirements
In better-effect, a Service can be requested directly inside 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))
})
}
}
The syntax is small:
const database = yield* Database
But there are two things happening.
At runtime, the Service is resolved from the current environment.
At typecheck time, Database becomes part of the requirements carried by that Effect.
The dependency comes from the code that actually uses it. There is no second list to maintain.
Layers carry both sides of the environment
A Layer describes an implementation that belongs to the application environment:
const UserRepositoryLive = Layer.make(
UserRepository,
() => new UserRepository()
)
This Layer provides UserRepository.
But the methods of UserRepository use Database, so the Layer also carries that requirement.
If we try to create a Runtime from this incomplete environment, TypeScript rejects it:
await Runtime.make(UserRepositoryLive, backend)
// ^^^^^^^^^^^^^^^^^^
// Database is required but not provided
Nothing needs to boot before the problem appears.
The DI backend doesn't need to throw.
A request doesn't need to hit the affected code path.
The environment is incomplete, so the application doesn't typecheck.
Adding the missing implementation completes it:
const DatabaseLive = Layer.make(
Database,
() => new Database()
)
const AppLive = Layer.merge(
DatabaseLive,
UserRepositoryLive
)
const runtime = await Runtime.make(AppLive, backend)
Typechecked wiring
I've been calling this relationship typechecked wiring.
It connects three parts of the application:
- the Services the code uses;
- the implementations the Layers provide;
- the programs a Runtime can execute.
yield* Database
│
▼
the program requires Database
│
▼
does the Layer provide Database?
│
no ├──────────► TypeScript error
│
yes
▼
the Runtime can execute the program
The Runtime keeps the exact Service environment inferred from its Layer.
That means the contract remains useful after startup:
await runtime.run(() =>
Effect.gen(async function* () {
const database = yield* Database
return Result.ok(database)
})
)
If the program asks for a Service that doesn't exist in that Runtime, TypeScript rejects the call.
This matters in applications with more than one environment.
A web server, a worker, a migration script and a test suite may all run different programs against different sets of Services.
The Runtime type makes that distinction visible.
Replacing implementations without losing the contract
Tests often need the same application structure with a different implementation.
Layers can be overridden explicitly:
const AppTest = Layer.override(
AppLive,
DatabaseTest
)
The database implementation changes, but the environment doesn't become untyped.
The resulting Layer still knows which Services it provides and which requirements remain.
This makes test environments easier to reason about without forcing application code to depend directly on a specific container.
Some dependencies also have a lifetime
Providing the correct object is only part of resource management.
A database connection, transaction, file or session also needs an owner.
Who is responsible for releasing it?
Does it live for one request, one job or the whole application?
better-effect uses Scope to make that ownership explicit.
A resource acquired by a scoped Layer belongs to the Runtime:
const DatabaseLive = Layer.scoped(
Database,
() => Database.connect(),
(database) => database.close()
)
It remains alive while the application Runtime is active.
A resource acquired inside a program belongs to that execution:
const connection = yield* Effect.acquireRelease(
() => pool.reserve(),
(connection, outcome) => connection.release(outcome)
)
When the execution ends, the resource is released.
This also allows cleanup logic to see whether the execution succeeded or failed, which is useful for cases such as commit and rollback.
During shutdown, the Runtime stops accepting new executions, waits for active ones to finish, releases scoped resources and finally disposes the configured backend.
Why build this instead of using Effect directly?
Effect already has strong answers for dependencies, environments, resources, concurrency and many other application concerns.
It also has a full runtime, fibers, structured concurrency, streams, schedules, queues and a broader ecosystem.
For applications that want that complete model, using Effect directly makes sense.
The question behind better-effect is slightly different:
What if I want some of those architectural guarantees, but I want to keep
better-result, Promises and the runtime my application already uses?
That choice defines the limits of the project.
better-effect doesn't provide:
- fibers;
- a custom scheduler;
- streams or queues;
- a public
Effect<A, E, R>type; - its own complete dependency injection container;
- a second error model.
Effect.gen delegates Result composition to better-result.
Dependency resolution and caching stay behind a backend. The core library is concerned with the contract around Services, environments and lifetimes.
Where better-effect fits
The project is mainly aimed at TypeScript developers who already like Result-based code but are reaching the point where error handling is no longer the only architectural concern.
It may be useful in projects that have:
- application Services and repositories;
- an explicit composition root;
- production and test environments;
- Clean Architecture or DDD boundaries;
- resources that need clear ownership;
- an existing DI container they don't want to replace.
It won't be the right tool for every application.
When Result types and generator composition are enough, better-result can be used on its own.
When the application wants a complete effect system and runtime, Effect is the stronger choice.
better-effect is exploring the smaller space between those two options.
The project is open source
better-effect is open source and available under the MIT license.
You can install it with:
npm install better-effect better-result
The project is still early, and I'm deliberately trying to keep the API small.
At this stage, I'm especially interested in feedback about the model itself:
- Does the Service API feel natural?
- Are incomplete-environment errors understandable?
- Does “typechecked wiring” describe the benefit clearly?
- Which responsibilities should stay outside the library?
Typed errors already make application code easier to understand.
better-effect is an experiment in bringing the same kind of visibility to application wiring and resource ownership.
Top comments (0)