Go-style error handling for TypeScript: errors are values you check, not control flow you catch; context is wrapped on at every layer, never lost; and the resulting chain is inspected at the call site with is/as/cause — never through static types.
This is a TypeScript port of the error-handling doctrine in efficientgo/core/errors (itself the modern successor to pkg/errors), built on the platform's own primitives: ES2022 Error.cause is the chain, Error.captureStackTrace trims constructor frames, and every function returns a plain Error.
import*aserrorsfrom"@superbuilders/errors"asyncfunctionloadUser(userId: string){constresponse=awaiterrors.try(fetch(`/api/users/${userId}`))if(response.error){throwerrors.wrap(response.error,"user fetch")}constbody=awaiterrors.try(response.data.json())if(body.error){throwerrors.wrap(body.error,"user body decode")}returnbody.data}When the fetch fails three layers down, the error that surfaces reads like a story told outermost-first:
user fetch: request timeout: socket closed
pnpm add @superbuilders/errors
ESM only. Requires a runtime with ES2022 Error.cause (Node 18+, all evergreen browsers, Bun, Deno).
Three rules carry the whole library:
- Construct with
errors.new, nevernew Error. You get chain-awaretoString, structuredtoJSON, and a stack trace that starts at your call site. - At every boundary where you catch, either handle or wrap.
errors.wrap(cause, "what this layer was doing")preserves the entire original — type, stack, chain — and prepends one clause of context. Wrap messages are noun phrases naming the operation ("user fetch","config parse"), not sentences; the chain's": "joints do the grammar. - Errors are opaque. Every constructor returns plain
Error. Do not encode the chain in types — a generic thread snaps at the first function boundary. When a caller needs to know what failed, ask the value:errors.isfor identity,errors.asfor class,errors.causefor the root.
Creates an error whose stack trace begins at the caller (the constructor frame is trimmed, as in Go). Use for original failures — points where your code determines something is wrong.
if(rows.length===0){throwerrors.new(`frontend ${frontendId} not found`)}Do not wrap an error you just created — errors.new with a complete message is the whole story at an origin point.
Returns a new error with originalError as its cause (the standard ES2022 mechanism — errors.cause(wrapped) and any tooling that reads .cause see the real chain). Use at every layer that catches an error it cannot handle.
constparsed=errors.trySync(()=>JSON.parse(raw))if(parsed.error){throwerrors.wrap(parsed.error,"manifest parse")}Awaits a promise into a Result instead of a throw — the port of Go's multiple-return, and the reason try/catch blocks disappear from consuming codebases:
typeResult<T,EextendsError=Error>=|{data: T;error: undefined}|{data: undefined;error: E}The union is discriminated: checking result.error narrows result.data to T in the other branch. The canonical shape is always the same three lines:
constresult=awaiterrors.try(db.query(sql))if(result.error){throwerrors.wrap(result.error,"user lookup")}// result.data is T hereIf the promise rejects with a non-Error value (strings, numbers — the ecosystem's sins), it is converted to an Error carrying the value's string form, so result.error is always a real Error.
The optional E parameter (errors.try<T, DatabaseError>(...)) is a caller assertion, not a runtime check — the same contract as a Go errors.As target. Prefer leaving it defaulted and using errors.as on the value.
The synchronous twin, for throwing APIs like JSON.parse:
consturl=errors.trySync(()=>newURL(raw))if(url.error){throwerrors.wrap(url.error,"endpoint url parse")}Walks the chain to the root: the first error whose cause is not itself an Error. Returns the error unchanged when there is no chain. (Go's errors.Cause.)
logger.error({root: errors.cause(err).message},"request failed")Reports whether any error in the chain is identical (===) to target. This enables the sentinel pattern — export a fixed error value, wrap it freely, and detect it anywhere up-stack (Go's errors.Is with ErrNotFound-style sentinels):
exportconstErrNotFound=errors.new("not found")// deep in a data layerthrowerrors.wrap(ErrNotFound,`user ${id}`)// at the API boundaryif(errors.is(result.error,ErrNotFound)){returnnewResponse(null,{status: 404})}Returns the first error in the chain that is an instanceof ErrorClass, or undefined. The one place a type parameter genuinely narrows — you get the concrete instance with its fields:
consthttpError=errors.as(result.error,HttpError)if(httpError){returnnewResponse(null,{status: httpError.status})}Works with abstract base classes.
Errors from new/wrap carry two non-enumerable helpers:
toString()renders the full message chain:"outer: middle: root". This is what template literals andString(err)produce.toJSON()renders a structured object —name,message,stack, a recursively serializedcause, and any enumerable own properties (anHttpError'sstatussurvives) — soJSON.stringifyand structured loggers like Pino emit the whole chain without custom serializers.
Both are defined non-enumerably (v4): they never appear in Object.keys, spreads, or serialized output, and the error object is otherwise a completely ordinary, mutable Error that any logger can handle.
| efficientgo/core | @superbuilders/errors | Notes |
|---|---|---|
errors.New(msg) | errors.new(msg) | both trim the constructor frame from the stack |
errors.Wrap(err, msg) | errors.wrap(err, msg) | chain via Error.cause instead of Unwrap(); wrap requires a real Error (the type system replaces Go's nil-check) |
errors.Newf / Wrapf | — | template literals make format variants pointless |
err.Error() → "a: b: c" | err.toString() | identical chain rendering |
errors.Cause(err) | errors.cause(err) | walk to the root |
errors.Is(err, target) | errors.is(err, target) | identity against sentinels |
errors.As(err, &target) | errors.as(err, Class) | returns the instance instead of an out-param |
multiple return (T, error) | errors.try / errors.trySync → Result<T> | the piece Go gets from the language and TypeScript needs a library for |
The philosophical inheritance matters more than the signatures: in both libraries every constructor returns the opaque error type, because the chain is a runtime structure. Version 4 removed this port's youthful attempt to say otherwise (WrappedError<C>, DeepestCause<E>, and a pile of overloads) — types that promised static knowledge of a dynamic chain and delivered casts.
0BSD © Bjorn Pagen