TypeScript can’t tell you what a function throws. There’s no throws clause in a signature, and JavaScript lets you throw strings, numbers, plain objects or undefined as easily as an Error. So the catch block is one of the few places where the compiler genuinely knows nothing, and how you deal with that is a good signal of whether you write defensive code or just code that compiles.
What strict mode puts in your catch block
Since TypeScript 4.4, the strict flag includes useUnknownInCatchVariables, which types the catch variable as unknown instead of any. So this, which compiled happily for years, now fails:
async function pay() {
try {
await chargeCard(500);
} catch (err) {
console.log(err.message);
}
}err1.ts(7,17): error TS18046: 'err' is of type 'unknown'.That’s the compiler being honest. chargeCard might reject with an Error, but nothing in its type promises that, and a library deep in the call stack might throw "timeout". If it does, err.message is undefined at runtime:
node -e 'try { throw "boom" } catch (err) { console.log(err.message) }'
# undefinedYou also can’t fix it by annotating the variable with a narrower type. TypeScript rejects that outright:
err2.ts(4,15): error TS1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified.The reason is the same: an annotation would be a promise about every possible throw site, and nothing checks it.
The cast that makes the error go away
The quickest way to silence TS18046 is a cast:
catch (err) {
console.log((err as Error).message); // compiles, lies if someone threw a string
}It compiles, and in most runs it even works. But an as assertion doesn’t check anything; it just tells the compiler to stop asking. When the thrown value isn’t an Error, you log undefined, or worse, call err.stack.split("\n") and throw a new TypeError from inside your error handler, which hides the original failure. Interviewers ask about this cast specifically because it’s so common in code reviews.
Narrowing costs one or two lines and is actually correct:
function describeError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}After instanceof Error, err is Error and .message is safe. The string case covers the most common non-Error throw. The JSON.stringify fallback handles thrown objects like { code: 42 }, and the inner try covers circular objects, which make stringify throw. A helper like this usually lives in one shared module so every catch block can use it.
One caveat worth knowing for the interview: instanceof checks the prototype chain of the current JavaScript realm. An Error created in an iframe or a Node vm context isn’t an instance of your realm’s Error. That’s rare in application code but explains odd results in test runners that use separate contexts.
Promise rejections are still any
Here’s the gap useUnknownInCatchVariables doesn’t close:
chargeCard(5).catch((err) => {
const n: number = err.whatever.deeply; // compiles: err is any here
});No error. The .catch and second .then callbacks are typed in the standard library with a reason: any parameter, and the flag only affects try/catch syntax. If your code base mixes styles, annotate the callback parameter yourself, (err: unknown) => .... That’s allowed because a callback that accepts anything can stand in for one that’s handed any, and from then on err.whatever fails with the same TS18046 as a try/catch. Linters such as typescript-eslint have rules that flag this, and switching to async/await with try/catch removes the issue entirely.
Custom error classes that survive narrowing
When callers need to react differently to different failures, a subclass gives them something to narrow on:
class PaymentDeclinedError extends Error {
override readonly name = "PaymentDeclinedError";
constructor(readonly declineCode: string, options?: ErrorOptions) {
super(`Card declined: ${declineCode}`, options);
}
}
async function payTyped() {
try {
await chargeCard(500);
} catch (err) {
if (err instanceof PaymentDeclinedError) {
return { ok: false as const, code: err.declineCode };
}
throw new Error("Payment failed", { cause: err });
}
}Three details carry weight. Setting name makes logs readable, because the default would just be "Error". The declineCode field is available only after the instanceof check, so callers can’t read it by accident. And rethrowing with { cause: err } (an ES2022 feature, typed through ErrorOptions in lib.es2022) keeps the original error attached rather than replacing it, which matters when someone reads the stack trace at 2am. If your build still targets ES5, there’s an old trap here: downlevelled classes that extend Error lose their prototype, so instanceof PaymentDeclinedError returns false unless the constructor calls Object.setPrototypeOf(this, new.target.prototype). With an ES2015 or later target, native class syntax is emitted and the problem doesn’t exist, which is one more reason to drop ES5 output if nothing needs it.
Re-throwing unknown errors rather than swallowing them is the other half of good error handling: the catch block should only handle what it understands.
Expected failures as values
Exceptions are a poor fit for failures that are part of normal operation, like a form field that doesn’t parse or a coupon that’s expired. The caller has no type-level signal that they need to handle them. A Result union puts the failure in the return type:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type ParseError = { kind: "invalid-json" } | { kind: "missing-field"; field: string };
function parseOrder(json: string): Result<{ id: string }, ParseError> {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch {
return { ok: false, error: { kind: "invalid-json" } };
}
if (typeof raw !== "object" || raw === null || !("id" in raw) || typeof raw.id !== "string") {
return { ok: false, error: { kind: "missing-field", field: "id" } };
}
return { ok: true, value: { id: raw.id } };
}Callers can’t reach the value without checking ok first:
const r = parseOrder('{"id":"o_1"}');
console.log(r.value);err2.ts(28,15): error TS2339: Property 'value' does not exist on type 'Result<{ id: string; }, ParseError>'.
Property 'value' does not exist on type '{ ok: false; error: ParseError; }'.After if (r.ok), r.value.id is fine, and in the else branch r.error.kind narrows to the specific failure. Notice the JSON.parse call still sits in a try, because it really can throw; the function converts that one expected exception into a value. Programming errors and outages, such as a database connection dropping, should still throw. Trying to model every possible failure as a Result makes signatures noisy without making anything safer.
Interview practice
A colleague’s PR wraps every API call in catch (e) { toast((e as any).response.data.message) }. What would you say in review, and what would you suggest instead?
Answer
The as any turns off checking for the whole expression, so if the request fails before a response exists (DNS failure, CORS, a timeout, or the user going offline), e.response is undefined and reading .data throws a TypeError inside the catch block. The user sees nothing, and the original error is lost. I’d suggest one shared function, toUserMessage(err: unknown): string, that narrows properly: check for the HTTP client’s own error type (most clients export a type guard such as isAxiosError), then check that a response exists and that response.data.message is a string, then fall back to err instanceof Error ? err.message : "Something went wrong". The catch blocks call that helper. For responses the UI expects routinely, such as a 422 validation error, I’d return a Result from the API layer so the form code handles field errors through the type system rather than through exceptions. And I’d make sure unexpected errors are still reported to logging with their cause intact, not only shown as a toast.