TypeScript Error Handling: unknown in catch, Error Subclasses, cause, Result Types and Exhaustive Checks
Key takeaways
TypeScript cannot type what a function throws, so error handling is where the type system helps least by default. This guide covers what you can do: narrow unknown catch variables, write Error subclasses correctly, keep the original error with cause, return typed Results where failure is expected, and make the compiler reject unhandled error cases.
TypeScript cannot tell you what a function throws. There is no throws clause, and a call’s signature says nothing about its failure modes. Anything can throw, including a string, undefined or an object from a third-party library. Everything below follows from that limitation: some patterns work around it, and others move errors into places the type checker can see.
All code in this article was compiled with the TypeScript 7 compiler in strict mode and run on Node.js 24.
The catch variable is unknown, and that is correct
try {
JSON.parse(input)
} catch (e) {
console.log(e.message)
// error TS18046: 'e' is of type 'unknown'.
}
The useUnknownInCatchVariables flag, which is part of strict, types catch variables as unknown instead of any. Recent TypeScript releases turn strict on by default. Older projects get it from "strict": true in tsconfig.json. People tend to “fix” the error with catch (e: any) or (e as Error).message. Both silence the compiler, and both crash at runtime when someone throws a non-Error:
throw 'timeout' // legal JavaScript
throw { code: 42 } // also legal, common in older libraries
Promise.reject() // rejects with undefined
Narrow instead. For most code, one small normaliser at the boundary is enough:
function toError(e: unknown): Error {
if (e instanceof Error) return e
return new Error(typeof e === 'string' ? e : JSON.stringify(e))
}
try {
JSON.parse(input)
} catch (e) {
const err = toError(e)
logger.warn(err.message)
}
instanceof Error has one gap. It checks the prototype chain of the current realm, so an Error created in another realm (an iframe, a vm context, some worker setups) fails the check. For those cases there is Error.isError(). It is a newer addition to the language, so check that your runtime has it before you rely on it.
Error subclasses: name, cause, and the ES5 prototype trap
Custom error classes are worth having. instanceof checks read well, error trackers group by name, and you can attach structured fields:
export class AppError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = new.target.name // "AppError", or the subclass name
}
}
export class NotFoundError extends AppError {
constructor(readonly resource: string, readonly id: string) {
super(`${resource} ${id} not found`)
}
}
const e = new NotFoundError('User', '42')
e instanceof NotFoundError // true
e instanceof AppError // true
e.name // "NotFoundError"
Setting this.name = new.target.name in the base class means subclasses do not have to repeat it. Without it, every subclass reports itself as Error in stack traces and logs. One caveat: minifiers rename classes, so if you bundle server code with minification, set name explicitly as a string literal.
The ES5 trap
If code is compiled to ES5, class X extends Error breaks. ES5 has no real class inheritance from built-ins: calling Error as a function returns a new object and ignores this. As a result, instanceof NotFoundError is false and methods declared on the subclass are undefined. TypeScript 7’s --target list starts at ES2015, so the modern compiler no longer produces this output. You still meet it in older projects, and in toolchains that down-level classes for old browsers. The historical fix is one line in the constructor:
Object.setPrototypeOf(this, new.target.prototype)
If you see a codebase full of those calls, check the build target. Once it is ES2015 or later, the calls can go.
Keep the original error with cause
Wrapping a low-level error in a meaningful one used to throw away the original stack. The ES2022 cause option keeps it:
async function loadConfig(path: string) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch (e) {
throw new AppError(`Failed to load config from ${path}`, { cause: e })
}
}
Node.js prints the cause chain in uncaught error output, and major error trackers show it too. The typing needs "lib": ["es2022"] or later (or a target that implies it). With an older lib you get error TS2554: Expected 0-1 arguments, but got 2. on the constructor and Property 'cause' does not exist on type 'Error' when you read it. cause is typed unknown, for the same reason as the catch variable.
Result types: make expected failures part of the signature
Exceptions are invisible in types. For failures the caller must decide on, return them instead:
type Ok<T> = { ok: true; value: T }
type Err<E> = { ok: false; error: E }
type Result<T, E> = Ok<T> | Err<E>
const ok = <T>(value: T): Ok<T> => ({ ok: true, value })
const err = <E>(error: E): Err<E> => ({ ok: false, error })
type ChargeError =
| { kind: 'card_declined'; code: string }
| { kind: 'insufficient_funds' }
| { kind: 'network'; retryable: boolean }
async function charge(amount: number): Promise<Result<Receipt, ChargeError>> {
// ...
}
const r = await charge(1999)
if (!r.ok) {
// r.error is ChargeError here; r.value does not exist on this branch
return showPaymentError(r.error)
}
sendReceipt(r.value)
The ok property is the discriminant. Checking it narrows the union, so reading r.value without the check is a compile error. The type narrowing guide covers how that works.
Where Results help and where they hurt
Results earn their keep for domain failures: validation, not-found, permission denied, payment declined, a rate limit you plan to retry. The caller has a real decision to make, and the type forces the decision.
They are the wrong tool for bugs and infrastructure failures: a null dereference, a lost database connection, an out-of-memory condition. No caller three layers up can do anything useful with { kind: 'db_connection_lost' } except pass it further up. Exceptions already do that, and a top-level handler turns them into a 500 and an alert.
I have seen codebases convert every function to return a Result after reading about Rust. Within a few months, half the code was if (!r.ok) return r plumbing, and the real bugs were still thrown by the libraries underneath, where no Result could catch them. The version that lasted used Results at a few domain boundaries, such as the payment service, input validation and external API clients, and plain exceptions everywhere else.
neverthrow, if you want chaining
The neverthrow library provides Result and ResultAsync with map, andThen, mapErr and match, so you can chain steps without an if after each one:
import { ok, err, ResultAsync, safeTry } from 'neverthrow'
type User = { id: string; name: string }
type FetchError =
| { kind: 'http'; status: number }
| { kind: 'network'; cause: unknown }
| { kind: 'parse'; cause: unknown }
function fetchUser(id: string): ResultAsync<User, FetchError> {
return ResultAsync.fromPromise(
fetch(`/api/users/${id}`),
(cause): FetchError => ({ kind: 'network', cause }),
)
.andThen((res) =>
res.ok ? ok(res) : err<Response, FetchError>({ kind: 'http', status: res.status }),
)
.andThen((res) =>
ResultAsync.fromPromise(
res.json() as Promise<User>,
(cause): FetchError => ({ kind: 'parse', cause }),
),
)
}
const greeting = await fetchUser('1').match(
(user) => `Hello, ${user.name}`,
(e) => `Failed: ${e.kind}`,
)
// Combine several: the first Err wins
const both = await ResultAsync.combine([fetchUser('a'), fetchUser('b')])
// Generator syntax, close to Rust's ?
const pair = await safeTry(async function* () {
const a = yield* fetchUser('a')
const b = yield* fetchUser('b')
return ok([a, b] as const)
})
In neverthrow 8, combine and combineWithAllErrors are static methods on Result and ResultAsync, not standalone imports. Older tutorials that do import { combine } from 'neverthrow' no longer compile.
res.json() as Promise<User> is a lie the compiler accepts: nothing checks the shape. For data from outside the process, validate it (next section).
Validate data at the boundary
Types describe what you expect to receive. A schema validator checks what actually arrived. Zod’s safeParse already returns a Result-shaped object:
import { z } from 'zod'
const UserSchema = z.object({
id: z.string(),
email: z.email(),
role: z.enum(['admin', 'user', 'viewer']),
})
type User = z.infer<typeof UserSchema>
const parsed = UserSchema.safeParse(await res.json())
if (!parsed.success) {
return err({ kind: 'invalid_response', issues: parsed.error.issues })
}
return ok(parsed.data) // User, checked at runtime
z.email() is the Zod 4 spelling. Zod 3 used z.string().email(), which Zod 4 still accepts but marks as deprecated. More in the Zod guide.
Exhaustiveness: make new error cases fail to compile
With errors in a discriminated union, you can make the compiler list every place that needs updating when you add a case:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`)
}
function describe(e: ChargeError): string {
switch (e.kind) {
case 'card_declined': return `Card declined (${e.code})`
case 'insufficient_funds': return 'Insufficient funds'
case 'network': return e.retryable ? 'Please retry' : 'Payment service unavailable'
default: return assertNever(e)
}
}
Remove the 'network' case and compilation fails:
error TS2345: Argument of type '{ kind: "network"; retryable: boolean; }'
is not assignable to parameter of type 'never'.
This is more useful than it looks. In a large codebase, adding { kind: 'fraud_suspected' } to the union immediately shows every switch that needs a decision. assertNever also throws at runtime, which matters when a value arrives from an API that grew a new case before your types did. Use a helper like this rather than a bare const _x: never = e. The bare version compiles the same, but at runtime it silently falls through and returns undefined.
Async errors: floating promises and allSettled
A promise nobody awaits is a promise whose rejection nobody sees:
async function onSave() {
saveDraft(doc) // no await, no .catch: a "floating" promise
closeEditor()
}
If saveDraft rejects, Node.js (since v15) treats the unhandled rejection as fatal and terminates the process. Browsers log it to the console, where nobody looks. The TypeScript compiler does not flag this. The typescript-eslint rule @typescript-eslint/no-floating-promises does, but it needs type-aware linting (parserOptions.projectService or project). I consider it the single most valuable lint rule for async TypeScript. When fire-and-forget is intentional, make it explicit with void saveDraft(doc).catch(reportError).
For parallel work, Promise.all rejects on the first failure, and the other promises keep running with their results discarded. Promise.allSettled waits for everything and tells you what failed. Note that reason is typed any in the standard library, so narrow it like a catch variable:
const results = await Promise.allSettled(ids.map(fetchUserOrThrow))
const users: User[] = []
const failures: Error[] = []
for (const r of results) {
if (r.status === 'fulfilled') users.push(r.value)
else failures.push(toError(r.reason)) // reason: any, so normalise it
}
One place that turns exceptions into responses
Exceptions that are not handled locally should reach exactly one handler that logs them and turns them into a response. In Express, that is a four-argument middleware registered after the routes:
import type { ErrorRequestHandler } from 'express'
const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
if (err instanceof NotFoundError) {
res.status(404).json({ error: err.message })
return
}
const e = toError(err)
req.log?.error({ err: e }, 'unhandled error')
res.status(500).json({ error: 'Internal server error' })
}
app.use(errorHandler)
Express 5 forwards a rejected promise from an async route handler to this middleware automatically. Express 4 does not: an async handler that throws there produces an unhandled rejection unless you wrap it in try { … } catch (e) { next(e) }. That difference is a common reason for a mysterious crash after copying a route from an Express 5 example into an Express 4 app. The Node.js error handling guide covers process-level handlers and operational vs programmer errors.
In React, error boundaries play the same role for rendering. Remember what they do not catch: errors in event handlers, in setTimeout callbacks and in promise chains. A failed onClick={async () => …} never reaches the boundary, so those handlers need their own try/catch or a Result.
Never send err.message from an unknown error to the client. Database drivers put SQL fragments and hostnames in their messages.
Choosing a pattern
| Situation | Pattern |
|---|---|
Any catch block | Narrow unknown with instanceof or a toError normaliser |
| Wrapping a lower-level failure | Custom Error subclass with { cause } |
| Expected domain failure the caller must decide on | Result with a discriminated-union error |
| Data from HTTP, files, queues | Schema validation (safeParse) at the boundary |
| Adding error cases over time | switch + assertNever |
| Async code | await everything, no-floating-promises, allSettled when partial success is fine |
| Bugs and infrastructure failures | Let them throw to a single top-level handler |