I write mostly TypeScript and some Go. When I switch back from Go, I miss explicit error returns. A function that can fail says so, and I deal with it right there.
In TypeScript I get catch (e), where e is unknown, thrown from some layer I forgot could throw.
I wrote errval: zero dependencies, 1.86 kB minified and gzipped.
const [err, user] = await getUser(id)
if (err) return fail(err)
user.email // narrowed. no undefined, no `!`
I don't write the error union manually, it's inferred from the calls to fail(). And match will not compile if I forget a case:
return match(err, {
NotFound: (e) => respond(404, e.id),
Forbidden: (e) => respond(403, e.reason),
DbError: (e) => respond(503, e.cause.message),
})
Return a new error type from the service and the match in the handler will stop compiling until it has a case for it.
I benchmarked it. A request handler where half the requests fail, Node 24.16, per request:
neverthrow: 198 ns
errval: 226 ns
try/catch: 2,623 ns
Effect
runSync: 3,952 ns
neverthrow is faster than mine. In that test its errors are bare objects with no name and no message, and mine are real instanceof Error objects with both, so I'm happy to leave the gap. Plain try/catch wins too when nothing fails at all: 195 ns vs my 203.
The large gap is on the failure path, and it comes from error construction. Creating an Error subclass took 1,978 ns. An errval error took 25 ns, because it never runs the Error constructor.
I didn't really build it for the speed. It was for the inferred error unions.
The error comes first in the tuple, not last like in Go. With the value first, const [value] = save() compiles and the error disappears. With the error first, the thing you can accidentally drop is the value.
It's 0.1 and it's just me. It works on Node, Bun and Deno, and the benchmark code is in the repo.
npm install errval
https://github.com/aymaneallaoui/errval
Would you use [err, value] in a TypeScript codebase? Why or why not?
Top comments (5)
answering directly: yeah, error-first is right. value-first still compiles a silent swallow, since
const [value] = save()type-checks fine and just drops a real failure. err first breaks that trick.dug into the source on the instanceof claim out of curiosity. TaggedBase never calls
new Error(). it just reparents the prototype so instanceof passes free, skipping the constructor entirely. that's the cheap default.two separate opt-ins are easy to conflate though.
native: trueroutes through a real Error constructor and your own type docs put that around 150ns.stack: truestays on the fast path but addsError.captureStackTrace, which those same docs call "a few microseconds" — that's actually the pricier knob, not native mode. might be worth flagging stack capture as the real cost, since native construction alone barely moves the number.you read it right and thanks for actually opening the source it makes it feel good that someone cared enough
one detail that makes your point stronger:
native: true is only cheap because
I set Error.stackTraceLimit to 0 around the super() call so the real Error
constructor runs but skips the stack capture that's the whole trick.
measured on Node 24: default 25 ns, native 164 ns, stack: true about 2,600 ns.
so yes, stack capture is the real cost in both knobs. the constructor itself
is nearly free.
one thing to not ignore Bun ignores stackTraceLimit so native: true is about 300 ns there,
basically the price of a plain new Error.
The inferred error unions are the most interesting part of errval for me. Bringing Go-style explicit error handling into TypeScript while keeping exhaustive
matchchecks could make failure paths much easier to reason about. The benchmark comparison is also useful because it makes the trade-offs clear instead of focusing only on the API design.Definitely an interesting approach for TypeScript developers who prefer explicit error handling. More developer tools and resources are available at codecan.net.
The benchmark split is useful because it separates API ergonomics from runtime cost. One thing I’d test before adopting is how the tuple behaves across async boundaries: do you preserve the original error cause and stack when wrapping at service boundaries, or does that need an explicit helper?
I’d 100% use this on my own side projects because I despise invisible try/catch blocks throwing from three layers deep.