Error Handling in TypeScript: unknown in catch, Narrowing Errors and the Result Pattern
We look at why the catch variable is unknown, narrowing with instanceof Error, custom error classes, Promise pitfalls, and modeling errors with Result and discriminated unions.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
Here's a small function that reads a port number from a configuration value. If the value is invalid it throws, and in catch it prints the error and falls back to a default port:
function readPort(raw: string): number {
try {
const port = Number(raw);
if (!Number.isInteger(port)) {
throw new Error(`Invalid port: ${raw}`);
}
return port;
} catch (error) {
console.log(error.message);
return 3000;
}
}
console.log(readPort("8080"));
You've seen this code hundreds of times in JavaScript. Do you think TypeScript compiles it? If it reports an error, on which line and why? It's right in front of you that the thing thrown in the try block is an Error; keep that in mind while you guess.
In JavaScript, Anything Can Be Thrown
As we saw in the JavaScript series, throw doesn't have to throw an Error. A string, a number, a plain object, even undefined can be thrown:
function risky(n: number): void {
if (n === 1) throw new Error("real error");
if (n === 2) throw "just a string";
if (n === 3) throw { code: 42 };
}
On top of that, your own throw isn't the only thing that runs in a try block; every function you call, and every library those functions call, can throw something too. TypeScript has no way of knowing what will arrive in catch. That's why, with useUnknownInCatchVariables (TS 4.4) turned on as part of the strict family, the catch variable is unknown. That's also the answer to the opening question:
error TS18046: 'error' is of type 'unknown'.
The error is on line 9, at error.message. We can see that what's thrown is an Error, but TypeScript can't guarantee what Number(raw), or some other line added there later, might throw. This variable used to be any, and error.message compiled without a single warning; when a string was thrown, it quietly printed undefined.
You can annotate the catch variable, but only as any or unknown. Writing a narrower type would mean promising TypeScript something it never checked:
function risky(n: number): void {
if (n === 2) throw "just a string";
}
try {
risky(2);
} catch (error: unknown) {
if (typeof error === "string") {
console.log(error.toUpperCase());
}
}
try {
risky(2);
// Error: Catch clause variable type annotation must be 'any' or 'unknown' if specified.
} catch (error: Error) {
console.log(error.message);
}
Narrowing unknown
We worked with unknown in the beginner series and in the type guards post: narrow first, then use. The most common narrowing in catch is instanceof Error. To avoid writing the same check in every catch, a small helper function does the job:
function risky(n: number): void {
if (n === 1) throw new Error("real error");
if (n === 2) throw "just a string";
if (n === 3) throw { code: 42 };
}
function toError(value: unknown): Error {
if (value instanceof Error) {
return value;
}
return new Error(String(value));
}
for (const n of [1, 2, 3]) {
try {
risky(n);
} catch (error) {
const err = toError(error);
console.log(err.message);
}
}
The console shows real error, just a string and [object Object], in that order. The last one shows how String() converts a plain object; in a real project it's more useful to print the value with JSON.stringify in that branch, or to log it as is. What matters is that after toError the value you hold really is an Error, and message always exists.
Your Own Error Classes
If you want to behave differently depending on the kind of error, you write your own classes that extend Error. instanceof narrowing works with these classes too, and it unlocks the properties that belong only to that class:
class HttpError extends Error {
readonly status: number;
constructor(status: number, message: string, options?: ErrorOptions) {
super(message, options);
this.name = "HttpError";
this.status = status;
}
}
function toError(value: unknown): Error {
if (value instanceof Error) {
return value;
}
return new Error(String(value));
}
async function loadUser(id: number): Promise<string> {
if (id === 404) {
throw new HttpError(404, `User ${id} not found`);
}
if (id === 0) {
throw "id must be positive";
}
return "Ada";
}
async function main() {
for (const id of [1, 404, 0]) {
try {
const name = await loadUser(id);
console.log("loaded:", name);
} catch (error) {
if (error instanceof HttpError) {
console.log("http:", error.status, error.message);
} else {
const err = toError(error);
console.log("other:", err.name, err.message);
}
}
}
}
main();
The output is loaded: Ada, http: 404 User 404 not found and other: Error id must be positive. In the first branch we can access error.status because TypeScript narrowed error to HttpError. Notice that the class declares an explicit status property instead of using parameter properties; we'll discuss in a later post why that syntax stays out of this series' examples.
When you catch an error and rethrow it as a more meaningful one, don't lose the original. The cause option from ES2022 exists for exactly this, and TypeScript's lib types define it as ErrorOptions:
function readConfig(text: string): unknown {
try {
return JSON.parse(text);
} catch (error) {
throw new Error("Config could not be parsed", { cause: error });
}
}
try {
readConfig("{ broken");
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
console.log(error.cause instanceof SyntaxError);
}
}
It prints Config could not be parsed and true. The type of cause is unknown as well; you need to narrow it before using the value inside.
Two Subtle Points with Promises
In the previous post we typed async functions. On the error side, two details are easy to miss:
async function fetchName(): Promise<string> {
throw new Error("Network down");
}
async function withoutAwait(): Promise<string> {
try {
return fetchName();
} catch {
return "fallback";
}
}
async function withAwait(): Promise<string> {
try {
return await fetchName();
} catch {
return "fallback";
}
}
withAwait().then((name) => console.log("withAwait:", name));
withoutAwait()
.then((name) => console.log("withoutAwait:", name))
.catch((reason) => console.log("rejected:", reason.message));
The output is withAwait: fallback and rejected: Network down. Because withoutAwait returns the promise without awaiting it, the rejection happens after the try block has finished, and catch never sees it. TypeScript accepts both functions with the same type; the difference is purely at runtime.
The second detail is on the last line: the reason parameter of the .catch callback is any, not unknown. That's how Promise is defined in the lib types, and useUnknownInCatchVariables only affects try/catch. That's why reason.message compiled without a warning. You can bring back the same safety yourself by writing .catch((reason: unknown) => …).
The Signature Doesn't Tell: The Result Pattern
In TypeScript, a function's signature doesn't say what it can throw; there's nothing like Java's throws. Someone looking at the signature readPort(raw: string): number can't tell that the function might blow up on invalid input. Moving expected failures into the return type solves this. The Result type we saw with generic types is made for exactly this:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type PortError =
| { kind: "empty" }
| { kind: "not-a-number"; input: string }
| { kind: "out-of-range"; value: number };
function parsePort(raw: string): Result<number, PortError> {
const text = raw.trim();
if (text === "") {
return { ok: false, error: { kind: "empty" } };
}
const port = Number(text);
if (!Number.isInteger(port)) {
return { ok: false, error: { kind: "not-a-number", input: text } };
}
if (port < 1 || port > 65535) {
return { ok: false, error: { kind: "out-of-range", value: port } };
}
return { ok: true, value: port };
}
Failure is now part of the signature. Whoever calls parsePort has to check ok before reaching value; skip the check and TypeScript won't allow it:
// Error: Property 'value' does not exist on type 'Result<number, PortError>'.
console.log(parsePort("8080").value);
The ok property is the discriminant of the discriminated union. In the if (result.ok) branch TypeScript narrows result to the success side and value becomes available; in the else branch there is only error.
Modeling Errors with a Discriminated Union
PortError is itself a discriminated union. The error isn't a string message but a type in which each kind carries its own data. That way the caller decides how to show the error to the user, and an exhaustive check makes sure no kind is forgotten:
function describeError(error: PortError): string {
switch (error.kind) {
case "empty":
return "Port is required";
case "not-a-number":
return `"${error.input}" is not a number`;
case "out-of-range":
return `${error.value} is outside 1-65535`;
default: {
const unreachable: never = error;
return unreachable;
}
}
}
for (const raw of ["8080", "", "abc", "70000"]) {
const result = parsePort(raw);
if (result.ok) {
console.log("port:", result.value);
} else {
console.log("error:", describeError(result.error));
}
}
The output is port: 8080, error: Port is required, error: "abc" is not a number and error: 70000 is outside 1-65535. If tomorrow you add a new kind to PortError, say { kind: "reserved"; value: number }, the line in the default branch stops compiling:
error TS2322: Type '{ kind: "reserved"; value: number; }' is not assignable to type 'never'.
In other words, adding a new error kind means letting the compiler find every place where you have to handle it.
throw or Result?
They aren't rivals; they do different jobs. Use Result for situations that are expected and that the caller has to decide on: user input, form validation, a record that wasn't found. These are part of the program's normal flow. Use throw for the unexpected: a broken configuration, a dropped connection, a branch you'd describe as "we should never get here". Rather than carrying those through every layer one by one, it's cleaner to let a try/catch at the top level catch them.
The two meet at the boundary: you can wrap a throwing API like JSON.parse in try/catch in your own code and turn it into a function that returns a Result. That way errors from the outside enter your type system.
Common Pitfalls
Writing (error as Error).message after catch (error). It silences the compiler but checks nothing; when a string is thrown you still get undefined. Use instanceof or toError instead of an assertion.
Going back to the old behaviour with catch (error: any). It's valid syntax, but it reopens exactly the hole useUnknownInCatchVariables is trying to close.
Forgetting that the Promise .catch callback is any, and writing just return instead of return await in an async function while expecting catch to run.
Throwing anyway from inside a function that returns a Result. The caller no longer expects an exception; don't mix the two mechanisms in the same function.
A Small Experiment
★☆☆ Fix readPort from the start of the post: in catch, check error instanceof Error first, and print String(error) otherwise. Use npx tsc --noEmit to see the error disappear.
★★☆ Predict, then try: does if (result.ok === false) { console.log(result.error.kind); } compile? And what happens if you write result.value in the if (!result.ok) branch?
★★☆ Add the kind { kind: "reserved"; value: number } to PortError and make parsePort return this error for ports below 1024. Watch how the compiler leads you to describeError.
★★★ Write a safeParse function that wraps JSON.parse and returns Result<unknown, string>. Use try/catch inside, but never let a throw leak out.
There's one more way in TypeScript to model a fixed set of values like the kind values in PortError, and people coming from other languages reach for it right away: the enum. In the next post we'll cover enums, namespaces and parameter properties, the TypeScript-specific syntax that leaves code behind at runtime; we'll see why they don't run directly in Node and what you can use instead.