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 (1)
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.