Install any skill in seconds. Free to start, no credit card required.
Get Started Free →errore is Go-style error handling for TypeScript: return errors instead of throwing them. Instead of Go's two-value tuple (val, err), functions return a single Error | T union. Instead of checking err != nil, you check instanceof Error. TypeScript narrows the type automatically — forget to check and your code won't compile. No wrapper types, no Result monads, just unions and instanceof. The errore npm package provides helper utilities (createTaggedError, tryAsync, matchError, findCause, partitio
.claude/skills/saffron-health-errore/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 193% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 107% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 356% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 234% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 325% | 0% |
Go-style error handling for TypeScript. Functions return errors instead of throwing them — but instead of Go's two-value tuple (val, err), you return a single Error | T union. Instead of checking err != nil, you check instanceof Error. TypeScript narrows the type automatically. No wrapper types, no Result monads, just unions and instanceof.
tsconst user = await getUser(id) if (user instanceof Error) return user // early return, like Go console.log(user.name) // TypeScript knows: User
import * as errore from 'errore' — namespace import, never destructureunknown | Error — the union collapses to unknown, breaks narrowing. Common trap: res.json() returns unknown, so return await res.json() makes the return type MyError | unknown → unknown. Fix: cast with as → return (await res.json()) as Usertry-catch for control flow — use .catch() for async boundaries, errore.try for sync boundariescreateTaggedError for domain errors — gives you _tag, typed properties, $variable interpolation, cause, findCause, toJSON, and fingerprintingcause to wrap errors — new MyError({ ..., cause: originalError })| null for optional values, not | undefined — three-way narrowing: instanceof Error, === null, then valueconst + expressions, never let + try-catch — ternaries, IIFEs, instanceof Errorif branches with early exits, keep the happy path at root — like Go's if err != nil { return err }, check the error, exit (return/continue/break), and continue the success path at the top indentation level. This makes the happy path readable top-to-bottom with minimal nestingError handler in matchError — required fallback for plain Error instances.catch() for async boundaries, errore.try for sync boundaries — only at the lowest call stack level where you interact with uncontrolled dependencies (third-party libs, JSON.parse, fetch, file I/O). Your own code should return errors as values, not throw..catch() in a tagged domain error — .catch((e) => new MyError({ cause: e })). The .catch() callback receives any, but wrapping in a typed error gives the union a concrete type. Never use .catch((e) => e as Error) — always wrap.cause in .catch() callbacks — .catch((e) => new MyError({ cause: e })), never .catch(() => new MyError()). Without cause, the original error is lost and isAbortError can't walk the chain to detect aborts. The cause preserves the full error chain for debugging and abort detection.errore.try over errore.tryFn — they are the same function, but errore.try is the canonical nameerrore.isAbortError to detect abort errors — never check error.name === 'AbortError' manually, because tagged abort errors have their tag as .nameerrore.AbortError — so isAbortError detects them in the cause chain even when wrapped by .catch()isAbortError(result) first as its own early return, then result instanceof Error as a separate early return. Never nest isAbortError inside instanceof Error:ts const result = await fetchData({ signal }).catch( (e) => new FetchError({ cause: e }), ) if (errore.isAbortError(result)) return 'Request timed out' if (result instanceof Error) return Failed: ${result.message}
instanceof Error checks return. A const narrowed = result alias is redundant:ts const result = await fetch(url).catch((e) => new FetchError({ cause: e })) if (result instanceof Error) return Failed: ${result.message} await result.json() // TS knows result is Response here
instanceof Error early returns on one line — no { block, no extra lines. if (x instanceof Error) return x keeps the happy path readable and reduces visual noise. Only use a block when the branch has more than one statement:ts // good — one line, no block if (result instanceof Error) return result if (user instanceof Error) return user
// bad — block for a single return adds noise if (result instanceof Error) { return result }
return or throw the error (i.e. the error is intentionally swallowed), add a console.warn or console.error so failures are visible during debugging. Silent error swallowing makes bugs invisible:ts // BAD: error silently ignored — if sync fails you'll never know const result = await syncToCloud(data) if (result instanceof Error) { // nothing here — silent failure }
// GOOD: log before continuing — error is visible in logs const result = await syncToCloud(data) if (result instanceof Error) { console.warn('Cloud sync failed:', result.message) }
> Propagated errors (return error) don't need logging — the caller handles them. But errors you choose to ignore must leave a trace. This applies to loops with continue, fallback branches, and any path where the error is intentionally dropped.
({id, retries}) not (id, retries) for functions with 2+ params.map/.filter instead of let + mutationif (a && b) not if (a) { if (b) }any — search for proper types, use as unknown as T only as last resortcause not template strings — new Error("msg", { cause: e }) not new Error(msg ${e})let — use IIFE with returns instead of let x; if (...) { x = ... }const items: string[] = [] not const items = []import fs from 'node:fs' then fs.readFileSync(...), not named importsts // let inference do its job function getUser(id: string) { const user = await db.find(id) if (!user) return new NotFoundError({ id }) return user }
// explicit annotation when it adds clarity on a complex public API function processRequest( req: Request, ): Promise<ValidationError | AuthError | DbError | null | Response> { // ... }
.filter(isTruthy) not .filter(Boolean) — Boolean doesn't narrow types, so (T | null)[] stays (T | null)[] after filtering. Use a type guard:ts function isTruthy<T>(value: T): value is NonNullable<T> { return Boolean(value) } const items = results.filter(isTruthy)
controller.abort() must use typed errors — abort(reason) throws reason as-is. MUST pass a tagged error extending errore.AbortError, NEVER new Error() or a string — otherwise isAbortError can't detect it in the cause chain:ts class TimeoutError extends errore.createTaggedError({ name: 'TimeoutError', message: 'Request timed out for $operation', extends: errore.AbortError, }) {} controller.abort(new TimeoutError({ operation: 'fetch' }))
catch {} and unlogged error branches hide failures. With errore you rarely need catch at all, but at any boundary where an error is not propagated, always log it (see rule 20):ts const emailResult = await sendEmail(user.email).catch( (e) => new EmailError({ email: user.email, cause: e }), ) if (emailResult instanceof Error) { console.warn('Failed to send email:', emailResult.message) }
Keep block nesting minimal. Every level of indentation is cognitive load. The ideal function reads top to bottom at root level — checks and early returns, no else, no nested if, no try-catch.
Core pattern — call → check error → exit if error → continue at root. This is the single most important structural rule.
Go:
gouser, err := getUser(id) if err != nil { return fmt.Errorf("get user: %w", err) } // user is valid here, at root level posts, err := getPosts(user.ID) if err != nil { return fmt.Errorf("get posts: %w", err) } // posts is valid here, at root level return render(user, posts)
errore (identical structure):
tsconst user = await getUser(id) if (user instanceof Error) return user const posts = await getPosts(user.id) if (posts instanceof Error) return posts return render(user, posts)
The reader scans the left edge of the function to follow the happy path — just like reading a Go function where if err != nil blocks are speed bumps you skip over.
No else — early return eliminates it: if (x) return 'A'; return 'B'
No else if chains — sequence of early-return if blocks:
tsfunction getStatus(code: number): string { if (code === 200) return 'ok' if (code === 404) return 'not found' if (code >= 500) return 'server error' return 'unknown' }
Flatten nested if — invert conditions and return early. if (A) { if (B) { ... } } becomes if (!A) return; if (!B) return; .... The transformation rule: take the outermost if condition, negate it, return the failure case, then continue at root level. Repeat for each nested if. The happy path falls through to the end.
Avoid try-catch for control flow — try-catch is the worst offender for nesting. It forces a two-branch structure (try + catch) and hides which line threw. Convert exceptions to values at boundaries:
tsasync function loadConfig(): Promise<Config> { const raw = await fs .readFile('config.json', 'utf-8') .catch((e) => new ConfigError({ reason: 'Read failed', cause: e })) if (raw instanceof Error) return { port: 3000 } const parsed = errore.try( () => JSON.parse(raw) as Config, (e) => new ConfigError({ reason: 'Invalid JSON', cause: e }), ) if (parsed instanceof Error) return { port: 3000 } if (!parsed.port) return { port: 3000 } return parsed }
Errors in branches, happy path at root — always handle errors inside if blocks, never success logic. Error handling goes in branches with early exits. Putting success logic inside if blocks inverts the flow and buries the happy path. If you see !(x instanceof Error) in a condition, you've inverted the pattern — flip it.
Keep the happy path at minimum indentation — the reader scans down the left edge to follow the main logic:
tsasync function handleRequest(req: Request): Promise<AppError | Response> { const body = await parseBody(req) if (body instanceof Error) return body const user = await authenticate(req.headers) if (user instanceof Error) return user const permission = checkPermission(user, body.resource) if (permission instanceof Error) return permission const result = await execute(body.action, body.resource) if (result instanceof Error) return result return new Response(JSON.stringify(result), { status: 200 }) }
Same in loops — error in if + continue, happy path flat:
tsfor (const id of ids) { const item = await fetchItem(id) if (item instanceof Error) { console.warn('Skipping', id, item.message) continue } await processItem(item) results.push(item) }
Always prefer const with an expression over let assigned later. This eliminates mutable state and makes control flow explicit. Escalate by complexity:
Simple: ternary
tsconst user = fetchResult instanceof Error ? fallbackUser : fetchResult
Medium: IIFE with early returns — when a ternary gets too nested or involves multiple checks, use an IIFE. It scopes all intermediate variables and uses early returns for clarity:
tsconst config: Config = (() => { const envResult = loadFromEnv() if (!(envResult instanceof Error)) return envResult const fileResult = loadFromFile() if (!(fileResult instanceof Error)) return fileResult return defaultConfig })()
> Every let x; if (...) { x = ... } can be rewritten as const x = ternary or const x: T = (() => { ... })(). The IIFE pattern is idiomatic in errore code — it keeps error handling flat with early returns while producing a single immutable binding.
tsimport * as errore from 'errore' class NotFoundError extends errore.createTaggedError({ name: 'NotFoundError', message: 'User $id not found in $database', }) {}
> createTaggedError gives you _tag, typed $variable properties, cause, findCause, toJSON, fingerprinting, and a static .is() type guard — all for free. > Omit message to let the caller provide it at construction time: new MyError({ message: 'details' }). The fingerprint stays stable. > Reserved variable names that cannot be used in templates: $_tag, $name, $stack, $cause.
Instance properties:
tserr._tag // 'NotFoundError' err.id // 'abc' (from $id) err.database // 'users' (from $database) err.message // 'User abc not found in users' err.messageTemplate // 'User $id not found in $database' err.fingerprint // ['NotFoundError', 'User $id not found in $database'] err.cause // original error if wrapped err.toJSON() // structured JSON with all properties err.findCause(DbError) // walks .cause chain, returns typed match or undefined NotFoundError.is(val) // static type guard
tsasync function getUser(id: string) { const user = await db.findUser(id) if (!user) return new NotFoundError({ id, database: 'users' }) return user }
> Return the error, don't throw it. The return type tells callers exactly what can go wrong.
tsconst user = await getUser(id) if (user instanceof Error) return user const posts = await getPosts(user.id) if (posts instanceof Error) return posts return posts
> Each error is checked at the point it occurs. TypeScript narrows the type after each check.
tsasync function fetchJson<T>(url: string): Promise<NetworkError | T> { const response = await fetch(url).catch( (e) => new NetworkError({ url, reason: 'Fetch failed', cause: e }), ) if (response instanceof Error) return response if (!response.ok) { return new NetworkError({ url, reason: `HTTP ${response.status}` }) } const data = await (response.json() as Promise<T>).catch( (e) => new NetworkError({ url, reason: 'Invalid JSON', cause: e }), ) return data }
> .catch() on a promise converts rejections to typed errors. TypeScript infers the union (Response | NetworkError) automatically. Use errore.try for sync boundaries (JSON.parse, etc.).
.catch() and errore.try should only appear at the lowest level of your call stack — right at the boundary with code you don't control (third-party libraries, JSON.parse, fetch, file I/O, etc.). Your own functions should never throw, so they never need .catch() or try.
For async boundaries: use .catch((e) => new MyError({ cause: e })) directly on the promise. TypeScript infers the union automatically. For sync boundaries: use errore.try(() => ..., (e) => ...). The .catch() callback receives any (Promise rejections are untyped), but wrapping in a typed error gives the union a concrete type — no as assertions needed.
tsasync function getUser(id: string) { const res = await fetch(`/users/${id}`).catch( (e) => new NetworkError({ url: `/users/${id}`, cause: e }), ) if (res instanceof Error) return res const data = await (res.json() as Promise<UserPayload>).catch( (e) => new NetworkError({ url: `/users/${id}`, cause: e }), ) if (data instanceof Error) return data if (!data.active) return new InactiveUserError({ id }) return { ...data, displayName: `${data.first} ${data.last}` } }
> Think of .catch() and errore.try as the adapter between the throwing world (external code) and the errore world (errors as values). Once you've converted exceptions to values at the boundary, everything above is plain instanceof checks. Your own functions return errors as values — they never need .catch() or try.
tsasync function findUser(email: string): Promise<DbError | User | null> { const result = await db .query(email) .catch((e) => new DbError({ message: 'Query failed', cause: e })) if (result instanceof Error) return result return result ?? null } // Caller: three-way narrowing const user = await findUser('alice@example.com') if (user instanceof Error) return user if (user === null) return console.log(user.name) // User
> Error | T | null gives you three distinct states without nesting Result and Option types.
tsconst [userResult, postsResult, statsResult] = await Promise.all([ getUser(id), getPosts(id), getStats(id), ]) if (userResult instanceof Error) return userResult if (postsResult instanceof Error) return postsResult if (statsResult instanceof Error) return statsResult return { user: userResult, posts: postsResult, stats: statsResult }
> Each result is checked individually. You know exactly which operation failed.
tsconst response = errore.matchError(error, { NotFoundError: (e) => ({ status: 404, body: { error: `${e.table} ${e.id} not found` }, }), DbError: (e) => ({ status: 500, body: { error: 'Database error' } }), Error: (e) => ({ status: 500, body: { error: 'Unexpected error' } }), }) return res.status(response.status).json(response.body)
> matchError routes by _tag and requires an Error fallback for plain Error instances. Use matchErrorPartial when you only need to handle some cases.
usingtry/finally has a structural problem: every resource adds a nesting level. Two resources = two levels of indentation. The business logic gets buried deeper with each resource, and cleanup is split across finally blocks far from where the resource was acquired. await using + DisposableStack keeps the function flat — one cleanup.defer() per resource, same indentation whether you have one resource or ten. Cleanup runs automatically in reverse order on every exit path.
tsconfig requirement: add "ESNext.Disposable" to lib:
jsonc{ "compilerOptions": { "lib": ["ES2022", "ESNext.Disposable"], }, }
Before — nested try/finally:
tsasync function importData(url: string, dbUrl: string) { const db = await connectDb(dbUrl) try { const tmpFile = await createTempFile() try { const data = await (await fetch(url)).text() await tmpFile.write(data) await db.import(tmpFile.path) return { rows: await db.count() } } finally { await tmpFile.delete() } } finally { await db.close() } }
After — flat with await using:
tsasync function importData(url: string, dbUrl: string): Promise<ImportError | { rows: number }> { await using cleanup = new errore.AsyncDisposableStack() const db = await connectDb(dbUrl).catch((e) => new ImportError({ reason: 'db connect', cause: e })) if (db instanceof Error) return db cleanup.defer(() => db.close()) const tmpFile = await createTempFile() cleanup.defer(() => tmpFile.delete()) const response = await fetch(url).catch((e) => new ImportError({ reason: 'fetch', cause: e })) if (response instanceof Error) return response await tmpFile.write(await response.text()) await db.import(tmpFile.path) return { rows: await db.count() } // cleanup: tmpFile.delete() → db.close() }
> await using guarantees cleanup on every exit path — normal return, early error return, or exception. Resources release in LIFO order. Adding a resource is one line (cleanup.defer()), not another nesting level. The errore polyfill handles the runtime; the tsconfig lib entry handles the types.
tsconst result = errore.try(() => JSON.parse(fs.readFileSync('config.json', 'utf-8')), ) const config = result instanceof Error ? { port: 3000, debug: false } : result
> Ternary on instanceof Error replaces let + try-catch. Single expression, no mutation, no intermediate state.
tsconst dbErr = error.findCause(DbError) if (dbErr) { console.log(dbErr.host) // type-safe access } // Or standalone function for any Error const dbErr = errore.findCause(error, DbError)
> findCause checks the error itself first, then walks .cause recursively. Returns the matched error with full type inference, or undefined. Safe against circular references.
tsclass AppError extends Error { statusCode = 500 toResponse() { return { error: this.message, code: this.statusCode } } } class NotFoundError extends errore.createTaggedError({ name: 'NotFoundError', message: 'Resource $id not found', extends: AppError, }) { statusCode = 404 } const err = new NotFoundError({ id: '123' }) err.toResponse() // { error: 'Resource 123 not found', code: 404 } err instanceof AppError // true err instanceof Error // true
> Use extends to inherit shared functionality (HTTP status codes, logging methods, response formatting) across all your domain errors.
tsasync function legacyHandler(id: string) { const user = await getUser(id) if (user instanceof Error) throw new Error('Failed to get user', { cause: user }) return user }
> At boundaries where legacy code expects exceptions, check instanceof Error and throw with cause. This preserves the error chain and keeps the pattern consistent.
{ data, error } ReturnsSome SDKs (Supabase, Stripe, etc.) return { data, error } instead of throwing. Destructure inline, check error first (truthy, not instanceof — most SDKs return plain objects), wrap in a tagged error, then continue with data:
tsconst { data, error } = await supabase.from('users').select('*').eq('id', id) if (error) return new SupabaseError({ cause: error }) if (data === null) return new NotFoundError({ id }) // data is narrowed here
> If the SDK's error is already an Error instance you can return it directly, but wrapping in a domain error is better — gives you _tag, typed properties, and cause chain. Check error with truthy check, not instanceof Error, since most SDK error objects are plain objects.
tsconst allResults = await Promise.all(ids.map((id) => fetchItem(id))) const [items, errors] = errore.partition(allResults) errors.forEach((e) => console.warn('Failed:', e.message)) // items contains only successful results, fully typed
> partition splits an array of (Error | T)[] into [T[], Error[]]. No manual accumulation.
controller.abort(reason) throws reason as-is — whatever you pass is what .catch() receives. This means you MUST pass a typed error extending errore.AbortError, never a plain Error or string.
Always use errore.isAbortError(error) to detect abort errors. It walks the entire .cause chain, so it works even when the abort error is wrapped by .catch().
tsimport * as errore from 'errore' class TimeoutError extends errore.createTaggedError({ name: 'TimeoutError', message: 'Request timed out for $operation', extends: errore.AbortError, }) {} const controller = new AbortController() const timer = setTimeout( () => controller.abort(new TimeoutError({ operation: 'fetch' })), 5000, ) const res = await fetch(url, { signal: controller.signal }).catch( (e) => new NetworkError({ url, cause: e }), ) clearTimeout(timer) if (errore.isAbortError(res)) return res if (res instanceof Error) return res
> isAbortError detects three kinds of abort: (1) native DOMException from bare controller.abort(), (2) direct errore.AbortError instances, (3) tagged errors that extend errore.AbortError — even when wrapped in another error's .cause chain.
Check signal.aborted before side effects or async operations — same early-return pattern as errors but for cancellation. Without these, cancelled work keeps running.
tsfor (const item of items) { if (signal.aborted) return // before work const data = await fetchData(item.id, { signal }) .catch((e) => new FetchError({ id: item.id, cause: e })) if (errore.isAbortError(data)) return // after async if (data instanceof Error) { console.warn(data.message); continue } if (signal.aborted) return // before write await db.save(data) }
> Place signal.aborted checks before expensive operations (network, db writes, file I/O). Check isAbortError after async calls that received the signal. Both keep the function responsive to cancellation.
If the project uses lintcn, read docs/lintcn.md for the no-unhandled-error rule that catches discarded Error | T return values.
ts// BAD: both sides of the union are Error instances type Result = MyCustomError | Error // instanceof Error matches BOTH — can't distinguish success from failure // Success types must never extend Error
Other measured skills in the registry, with their headline benchmark lift.