JavaScript lets you throw anything, and a catch block receives whatever was thrown. TypeScript cannot know what that is, so under strict the caught value is unknown, and your code must narrow it before use. This lesson shows how to do that cleanly, how to design custom error classes that carry structured data, when to return a Result instead of throwing, and how assertion helpers keep the happy path readable.
With useUnknownInCatchVariables (part of strict), the variable in a catch clause is unknown. Narrow it with instanceof or a small helper before touching properties:
try {
await saveOrder(order);
} catch (err) {
if (err instanceof Error) {
console.error(err.message);
} else {
console.error("Non-error thrown:", err);
}
}Because libraries and older code sometimes throw strings or plain objects, many teams normalise once:
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : JSON.stringify(value));
}Extending Error gives you instanceof checks and a place for structured fields such as a machine-readable code or an HTTP status. Set name so stack traces and logs show the subclass:
class HttpError extends Error {
constructor(readonly status: number, message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "HttpError";
}
}
class ValidationError extends Error {
constructor(readonly issues: { field: string; message: string }[]) {
super("Validation failed");
this.name = "ValidationError";
}
}
try {
throw new HttpError(404, "Not found", { cause: new Error("row missing") });
} catch (err) {
if (err instanceof HttpError && err.status === 404) { /* handle */ }
}The cause option (ES2022) chains the original error to the one you throw, preserving the root cause for logging. It requires lib or target of es2022 or later. If you compile to es5, add Object.setPrototypeOf(this, new.target.prototype) in the constructor, or instanceof will not recognise subclasses.
Throwing is invisible in a function signature: getUser(id): User says nothing about failure. For expected failures such as "not found" or "invalid input", returning a discriminated union makes the failure part of the type and forces callers to handle it:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseJson<T>(text: string, guard: (v: unknown) => v is T): Result<T, SyntaxError | TypeError> {
try {
const data: unknown = JSON.parse(text);
return guard(data) ? { ok: true, value: data } : { ok: false, error: new TypeError("Wrong shape") };
} catch (err) {
return { ok: false, error: err as SyntaxError };
}
}
const parsed = parseJson('{"id":1}', isUser);
if (!parsed.ok) {
console.error(parsed.error.message); // parsed.error: SyntaxError | TypeError
} else {
parsed.value.name; // parsed.value: User
}| Approach | Use for | Trade-off |
|---|---|---|
| throw | Bugs and unrecoverable failures | Invisible in types; unwinds the stack for free |
| Result union | Expected, recoverable outcomes | Explicit in types; callers must branch |
| null / undefined return | Simple "not found" | Loses the reason for failure |
A function that always throws has return type never. Declaring it lets control-flow analysis treat the call as terminal, so code after it needs no extra checks:
function fail(message: string): never {
throw new Error(message);
}
function loadPort(env: NodeJS.ProcessEnv): number {
const raw = env.PORT ?? fail("PORT is required"); // raw: string
return Number(raw);
}Assertion functions (asserts condition, covered in the type guards lesson) combine this with narrowing: assert(user, "missing user") both throws and removes undefined from user.
finally runs whether or not an error occurred and is the place to release locks, timers and connections. TypeScript 5.2 also supports explicit resource management: a using declaration calls the object's [Symbol.dispose]() at the end of the block (await using calls [Symbol.asyncDispose]()), even when an exception is thrown. Add esnext.disposable to lib and a polyfill if the runtime lacks it.
Error subclasses, never strings; strings have no stack trace.code field scales better than dozens of subclasses.cause chain once at the boundary (request handler, CLI entry), not at every layer.What is the type of `err` in `catch (err) { ... }` under `strict`?
strict, catch variables are unknown; narrow with instanceof Error or a toError helper.Error, set name, and carry structured fields such as code or status.cause to chain errors and keep the root cause available at the logging boundary.Result discriminated union for expected failures; reserve throw for bugs and unrecoverable states.never, which lets control-flow analysis skip unreachable checks.Next lesson: Decorators — annotate classes and members with reusable behaviour using the standard decorator syntax.