Q23 of 38 · TypeScript
How does TypeScript handle thrown errors in async functions, and why can't you type what a function throws?
Short answer
Short answer: TypeScript does not track thrown types — the `throws` clause does not exist in TypeScript. An async function that throws returns a `Promise<never>` (the rejected branch) but the rejection reason is untyped (`unknown` under strict mode). Model expected error paths explicitly with discriminated unions or result types instead.
Detail
TypeScript deliberately has no throws declaration in its type system. This is a design choice: Java-style checked exceptions proved unwieldy in practice, and JavaScript's dynamic nature makes exhaustive tracking impractical.
What this means:
- A function's signature tells you the resolved type but not the rejected type.
async function fetch(): Promise<User>can also reject with any error — the type system won't tell you which errors to handle.- Under strict mode,
catch (err)receivesunknown, requiring explicit narrowing.
Consequence for tests: A test that expects a specific exception type must use runtime checks (instanceof Error, err.message.includes(...)) because TypeScript cannot validate the thrown type.
Better pattern: Return a Result type instead of throwing for expected error paths:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
This makes error handling explicit and typed at the cost of making happy-path code more verbose.
When throwing is appropriate: Unexpected errors (programming errors, truly exceptional conditions) should throw. Expected business-logic outcomes (validation failure, not-found) benefit from result types.
// EXAMPLE
// TypeScript doesn't track what a function throws
async function fetchUser(id: number): Promise<User> {
if (id <= 0) throw new RangeError("ID must be positive");
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) throw new NotFoundError("User not found");
return res.json();
}
// Callers have no way to know RangeError or NotFoundError are possible
// Better: Result type for expected errors
type UserResult =
| { ok: true; user: User }
| { ok: false; error: "not-found" | "invalid-id" };
async function safeGetUser(id: number): Promise<UserResult> {
if (id <= 0) return { ok: false, error: "invalid-id" };
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) return { ok: false, error: "not-found" };
return { ok: true, user: await res.json() };
}
// Caller must handle both paths — TypeScript enforces it
const result = await safeGetUser(id);
if (!result.ok) {
console.error(result.error); // "not-found" | "invalid-id" — typed!
}