Typed Errors: Is Effect Solving Too Much?

Recently, one of my colleagues introduced Effect as a way to solve a problem we have in TypeScript: typed errors.

The problem is quite simple.

In normal TypeScript, I might write:

1
2
3
4
5
6
7
8
9
async function getUser(id: string): Promise<User> {
const user = await repository.findById(id);

if (!user) {
throw new UserNotFoundError(id);
}

return user;
}

The function signature tells me that it returns a User, but tells me nothing about how it can fail.

I might want the contract to be more explicit:

1
getUser(id): User throws UserNotFound

That sounds useful. And I think it is.

But then I looked at how we would express the same thing with Effect.

Instead of ordinary async/await:

1
2
3
const user = await getUser(id);
const account = await getAccount(user.accountId);
return account;

we might write something like:

1
2
3
4
5
6
return Effect.gen(function* () {
const user = yield* getUser(id);
const account = yield* getAccount(user.accountId);

return account;
});

And now the surrounding functions also need to speak Effect.

The error type becomes part of:

1
Effect<Value, Error, Dependencies>

and Effect introduces its own vocabulary:

1
2
3
4
5
6
7
8
Effect.gen
yield*
Effect.map
Effect.flatMap
Effect.catchTag
Effect.tryPromise
Effect.fail
...

None of these APIs are bad. In fact, they are powerful and well designed.

My concern is the cost of the programming model.

I started with a relatively small problem—“I want to know which business errors a function can produce”—but the solution seems to require changing how we write and read a large part of the application.

At some point, it no longer feels like adding a library to TypeScript. It feels like introducing another language inside TypeScript.

Is Typed Error Actually Worth Solving?

I think it is, but only for some errors.

Consider these:

1
2
3
UserNotFound
InsufficientBalance
AccountAlreadyClosed

These are business errors. The caller may actually need to make a decision based on them.

But what about:

1
2
3
4
DatabaseConnectionError
NetworkTimeout
UnexpectedState
ProgrammingError

Do I really want these errors propagated through every layer of the application and included in every function’s type?

Usually, I don’t.

Unexpected technical failures can often just propagate to an application-level error boundary, where they are logged and turned into an appropriate response.

So the problem I want to solve is narrower:

How can we make important business errors explicit without making every possible failure part of the type system?

Looking at Other Languages

Rust takes an interesting approach with:

1
Result<T, E>

and the ? operator:

1
let user = get_user(id)?;

The error is explicit in the type, but propagating it is almost free syntactically.

Swift takes another approach:

1
func getUser(id: ID) throws -> User

and now also supports typed throws:

1
func getUser(id: ID) throws(UserNotFoundError) -> User

I find both approaches interesting because error handling is supported directly by the language rather than requiring an entire effect abstraction.

What I Would Prefer

Ideally, TypeScript could have something conceptually like:

1
function getUser(id: string): User throws UserNotFound

and a lightweight propagation mechanism similar to Rust’s ?.

The compiler could understand the error contract and propagate it through the call chain.

At the same time, unexpected errors could remain ordinary JavaScript exceptions.

That would give us something like:

1
2
3
4
5
6
7
Business error

typed contract

compiler-assisted propagation

handle at the appropriate boundary

without requiring the entire application to adopt a new programming model.

So my current view is not that typed errors are a bad idea.

Quite the opposite.

I think they solve a real problem.

My question is whether Effect is solving too much to solve it.

What I really want is not an effect system just to get typed errors.

I want typed errors to feel like a natural part of the language.

Comments