Topic 13.5
Typed Error Handling
In one line
TypeScript can't say what a function throws, so errors need a plan: subclasses of Error you can tell apart with instanceof, a Result<T, E> union that makes failure part of the return type, cause to keep the original error, and using to clean up reliably.
Think of it like this
A postal service has two ways to say "I couldn't deliver". One is a loud siren: someone runs through the building shouting, and whoever is standing nearest has to deal with it (that is throw). The other is a note in the parcel slot: "delivered" or "failed, here's why" (that is a returned value). The siren interrupts everything and nothing on the parcel says it might sound. The note is part of the parcel, so you can't miss it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- throw
- Stop normal flow and jump to the nearest catch. Any value can be thrown.
- Error subclass
- Your own class extending Error, so one kind of failure has its own name and fields.
- Result<T, E>
- A union: either success with a value or failure with an error. The caller must check which.
- Discriminated union
- A union where one shared field (like ok) tells you which member you have.
- cause
- An option on Error that keeps the original error inside a new one.
- finally
- A block that runs after try and catch, whether or not there was an error.
- using
- A declaration that calls the value's Symbol.dispose when the block ends.
- Symbol.dispose
- A method name the language knows. using calls it for cleanup.
- Recoverable error
- A failure the caller can sensibly handle, like bad user input.
Step by step
01Throwing is invisible in the type
find below can throw, and nothing in (id: number) => string says so. A caller can forget the try.
function find(id: number): string {
if (id !== 1) throw new Error("no such user");
return "Asha";
}02Name each failure with a subclass
A class per kind of failure lets catch choose. Give it fields the handler needs, and set name.
class NotFoundError extends Error {
constructor(readonly what: string) {
super(what + " not found");
this.name = "NotFoundError";
}
}03Or return the failure
With a Result, the failure is in the return type. The caller checks ok and TypeScript narrows to the right side.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const r = parseAge("abc");
if (r.ok) {
r.value; // number
} else {
r.error; // string
}04Wrap with cause
When you catch a low-level error and throw a higher-level one, pass the original as cause. The chain stays intact.
try {
JSON.parse(text);
} catch (err) {
throw new Error("Invalid config file", { cause: err });
}05Clean up with using
using ties cleanup to the block. When the block ends by returning, falling off the end or throwing, Symbol.dispose runs. Several using in one block are disposed last first.
Try it yourself
- 1
Add a new failure
In the first example add
class ForbiddenError extends AppErrorwith code 403 and throw it forid === 3. Which branch handles it? (None, so it is rethrown and the Console shows an uncaught error.) Add a branch for it. - 2
Peek at a failed result
In the second example, move
console.log(r.value)before theif. Read the error. Then makeResultgeneric over the error with a default:type Result<T, E = string>and useResult<number>. - 3
Throw in the middle
In the
usingexample, move the lineif (fail) throw new Error("boom");to just beforeusing b = open("b.txt");. Predict which files are closed whenwork(true)runs: onlya.txt, becauseb.txtwas never opened.
Code & diagrams
NotFoundError is an AppError and an Error. The else branch rethrows anything unexpected, so real bugs are not hidden.
Console
(nothing was printed)Expected console output
Asha
NotFoundError 404 user 2 not found
trueInside the if, r.value exists. In the else, r.error does. Try printing r.value before the check and read the error.
Console
(nothing was printed)Expected console output
age is 43
problem: not a number: abc
problem: out of range: 200cause is typed unknown, because anything can be thrown. The original SyntaxError is still reachable once you check it.
Console
(nothing was printed)Expected console output
Invalid config file
trueBoth files are closed, last opened first, on success and when the function throws. If this example fails to run in your browser, it is too old for explicit resource management.
Console
(nothing was printed)Expected console output
open a.txt
open b.txt
working with a.txt b.txt
close b.txt
close a.txt
open a.txt
open b.txt
working with a.txt b.txt
close b.txt
close a.txt
caught boomBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use .value without checking ok
In the second example, replace the if (r.ok) { ... } else { ... } block with console.log(r.value);.
Myth vs fact
Myth
TypeScript tells me which errors a function can throw.
Fact
It doesn't. There is no throws in the type system. The : never return type marks a function that never returns normally, which is different.
Myth
Result replaces exceptions completely.
Fact
You still need exceptions for bugs and for libraries that throw. A Result is for expected failures.
Myth
using is only for files.
Fact
It works for anything with Symbol.dispose: locks, database transactions, timers, temporary event listeners.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Subclassing built-ins relies on the class target. With
target: es5,class X extends Errorproduced objects that failedinstanceof X; with ES2015+ targets it works. TypeScript 7 no longer acceptses5, so the old workaround (Object.setPrototypeOf(this, X.prototype)) is not needed. - ▸
A
Resultcosts an allocation per call and adds ceremony in chains. Libraries likeneverthrowaddmapandandThenhelpers; many teams just writeif (!r.ok) return rby hand. Keep the error type a union of string-literal codes or a discriminated union so callers canswitchexhaustively (Topic 4.6). - ▸
usingdesugars totry/finallywith a stack of disposables, and a disposal that throws while another error is in flight produces aSuppressedErrorholding both. TypeScript typesusingvariables as read-only and requires the value to have[Symbol.dispose]()or benull/undefined.
Remember this
- 1
The problem this solves: when something fails, the function's type says nothing about it, so callers forget to handle the failure or handle the wrong one. The patterns here put failures back where the compiler and the reader can see them. In JavaScript,
throwcan throw anything, and a function's type has nothrowspart. Look atfunction parse(text: string): number: it may throw, it may not, and the type doesn't say. That's whycatch (err)isunknown(Topic 13.3): the compiler honestly knows nothing. Error handling in TypeScript is therefore about conventions that put the information back. - 2
The first convention is **custom
Errorsubclasses**:class NotFoundError extends Error. Each kind of failure becomes its own class, so acatchcan tell them apart withinstanceof(Topic 4.4), and each class can carry typed extra fields such as a statuscode. Setthis.nameso stack traces and logs show the right name. Two details:instanceofworks for subclasses ofErrorwhen the code targets modern JavaScript (the oldes5target broke it;es5is no longer accepted as a target in 7.0), and you should rethrow errors you don't recognise instead of swallowing them (else throw err). - 3
The second convention is the **
Result<T, E>type**: a function doesn't throw for expected failures, it returns a union of success and failure:{ ok: true; value: T } | { ok: false; error: E }. This is a discriminated union (Topic 4.5) withokas the tag. The caller *can't* read.valueuntil it has checked.ok, because on the failure sidevaluedoes not exist. The compiler now makes the caller handle failure: forgetting is a type error. Rust makes this its standard (itsResulttype), and Go returns an error value next to the result; in TypeScript you choose it. - 4
When to use which? Use a
Resultfor expected, recoverable failures the caller should decide about: invalid input, "user not found", a 404. Usethrowfor bugs and unexpected situations nobody can sensibly handle at that spot: broken invariants, out of memory, a failure deep inside a library. A good rule: if the caller would writetry/catcharound every call, return aResult. If failure means the program is wrong, throw. - 5
**
cause** (an option onnew Error(message, { cause })) is a standard way to wrap a low-level error in a higher-level one without losing it: "Could not load config" with the originalSyntaxErrorunderneath. TypeScript types it asunknown(like every thrown value), so check it before use. Wrapping gives users a clear message and developers the full chain. - 6
Cleanup is the third error problem: a file, lock or connection must be closed whether the code succeeded or threw. The old pattern is
try { ... } finally { close() }. TypeScript 5.2 added **using**, from the explicit resource management feature of JavaScript:using file = openFile()callsfile[Symbol.dispose]()automatically when the block ends, even if an error is thrown, and disposes in reverse order of creation.await usingis the same withSymbol.asyncDispose. It is not available everywhere yet: check the MDN compatibility table for your browsers and Node version, and older runtimes need a polyfill forSymbol.dispose.
Explain it without notes
Why can't TypeScript type what a function throws, and what do teams do about it?
When would you return a Result instead of throwing?
What does cause give you, and what is its type?
What does using do, and how is it different from try/finally?
Practice
Define type Result<T, E> and write safeJsonParse(text: string): Result<unknown, string> that never throws.
Write a class Timer with a [Symbol.dispose] that logs stopped, and use it with using in a function that throws.
Trade-offs
- ↔
Exceptions keep the happy path clean but hide failure from the types.
Resultmakes failure visible but every caller must handle or pass it on. - ↔
Subclasses give precise
instanceofchecks, but a large hierarchy of error classes is hard to maintain. Acodestring or a discriminated union is often enough.
Done when you can
I can write an
Errorsubclass and handle it withinstanceof.I can write and use a
Result<T, E>and explain why.valueneeds the check.I can decide between throwing and returning a
Result.I can wrap an error with
causeand read it safely.I can use
usingand say in which order resources are disposed.