Chapter 21 of 24 · TypeScript for Beginners — Complete Free Tutorial
Error Handling and Result Types
The TypeScript for Beginners series has typed values, functions, promises and components — but not failure. That gap is real: exceptions are the one JavaScript mechanism TypeScript cannot describe. This twenty-first chapter of the 24-chapter series explains why throw is untyped, how to handle what you catch safely, and how a discriminated-union Result<T, E> puts failure back into the type system where your call sites can see it.
Why exceptions are untyped
TypeScript's type system describes values that flow through your program. An exception does not flow — it unwinds the stack, skipping every return type you declared. The language has no throws clause, and no annotation tells a caller what a function might raise:
| What you might expect | What TypeScript actually gives you |
|---|---|
catch (err: ApiError) | Rejected by the compiler — error TS1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified. |
| A signature listing thrown types | Does not exist; nothing about a function's declaration hints that it can fail |
throw accepts only errors | It accepts anything — strings, numbers, plain objects and null all throw happily |
| Exhaustive failure handling | Unchecked; a forgotten branch becomes a runtime surprise, not a compile error |
The consequence is a discipline rather than a feature: because the compiler will not check thrown values, you decide what a function throws, you document it, and you narrow it at the boundary. Everything else — expected, branchable failure — you return instead.
Catching safely: unknown and narrowing
Under strict, useUnknownInCatchVariables types the catch parameter as unknown, which means no property access is allowed until you have narrowed it. Three checks cover nearly every real error:
function messageOf(err: unknown): string {
if (err instanceof Error) return err.message; // built-in or your subclass
if (typeof err === 'string') return err; // throw 'something broke'
if (err && typeof err === 'object' && 'message' in err) {
return String((err as { message: unknown }).message);
}
return 'Unknown error';
}
try {
JSON.parse('{ not json }');
} catch (err) {
console.log(messageOf(err));
}
Expected output: the parser's own message — current Node prints Expected property name or '}' in JSON at position 2 (line 1 column 3), older runtimes print Unexpected token ... — extracted without a single unchecked property read. Note the order: instanceof first, then primitives, then a structural check. When several call sites need the same answer, extract the first check as a reusable guard: function isError(value: unknown): value is Error { return value instanceof Error; }. That type predicate narrows the value so err.name is a string with no assertion — the same technique you met in Narrowing and Type Guards, applied at the one place where types are weakest.
Results: failure you can see in the signature
An alternative treats failure as a value. A Result is a discriminated union of success and failure, so the ok property decides which branch exists:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Expected output: no output — but from now on, a function like parseConfig(text: string): Result<Config, ParseError> announces its failure mode in the one place every caller must read: the return type. Compare the two styles:
| Throwing | Returning a Result |
|---|---|
| Caller can ignore failure completely | Caller must handle both branches to use the value |
| Type of failure is invisible | E is part of the signature |
| Stack traces for free | No unwinding; you build context yourself |
| Right for programmer errors and request boundaries | Right for expected failure: parsing, validation, network, conversion |
Neither is universally correct. Throw for bugs and for failures nobody can recover from; return a result for outcomes the caller genuinely branches on. The hybrid in the worked example below uses both: it catches internally, then returns the outcome.
Exhaustive handling with never
A discriminated union only helps if every branch is handled. Make the compiler prove it by writing a function whose final case can never be reached:
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
type Status = 'idle' | 'loading' | 'success' | 'error';
function labelFor(status: Status): string {
switch (status) {
case 'idle': return 'Nothing yet';
case 'loading': return 'Working...';
case 'success': return 'Done';
case 'error': return 'Failed';
default: return assertNever(status);
}
}
Expected output: labelFor('loading') returns Working.... Now add a fifth member to Status and the switch fails to compile — ex.ts(11,33): error TS2345: Argument of type '"cancelled"' is not assignable to parameter of type 'never'.
The error appears exactly at the branch that has not been written yet. This is the payoff of chapter 21: a type that describes failure states, a switch that must cover them, and a compiler that refuses to let the two drift apart.
Optional Error subtypes
Plain Error loses information, so model the failures you actually handle. A subclass carries typed fields and still passes every instanceof Error check:
export class HttpError extends Error {
constructor(readonly status: number, readonly url: string) {
super(`HTTP ${status} from ${url}`);
this.name = 'HttpError';
}
}
export class ParseError extends Error {
constructor(message: string, readonly snippet: string) {
super(message);
this.name = 'ParseError';
}
}
Set this.name in the constructor so console.log output is readable, and keep the constructors' parameter types simple — every field you add is a field call sites must narrow to. One caveat from Classes and Access Modifiers: with target: "ES5" extending built-ins breaks instanceof unless you restore the prototype with Object.setPrototypeOf(this, new.target.prototype). Modern targets (ES2015 and later) do it for you.
Worked example: parseJson and safeFetch
Here is the pattern in full: parse defensively, fetch defensively, and let callers switch on a value they can trust.
Reusing the Result type defined above (exported from its module):
export function parseJson<T>(text: string): Result<T, ParseError> {
try {
return { ok: true, value: JSON.parse(text) as T };
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : String(err);
return { ok: false, error: new ParseError(detail, text.slice(0, 40)) };
}
}
export async function safeFetch<T>(
url: string,
init?: RequestInit,
): Promise<Result<T, HttpError | ParseError>> {
const response = await fetch(url, init);
if (!response.ok) {
return { ok: false, error: new HttpError(response.status, url) };
}
const text = await response.text();
return parseJson<T>(text);
}
Consuming it costs one if, and inside each branch the type is exact — no assertion, no optional chaining:
const result = await safeFetch<Course[]>('/api/courses');
if (result.ok) {
console.log(`loaded ${result.value.length} courses`);
} else {
console.log(`${result.error.name}: ${result.error.message}`);
}
Expected output: exactly one of the two lines, depending on the world — either loaded 3 courses, or HttpError: HTTP 404 from /api/courses / ParseError: Unexpected token '<', "<html><bod"... is not valid JSON.
What makes this work is that ok is a literal type in each union member. Narrowing on it is the same mechanism as narrowing a Status string, so the pattern scales from a two-member union to a full request state machine without new machinery.
Common mistakes
- Annotating the catch parameter —
catch (e: ApiError)is a compile error (TS1196); narrow theunknownyou are given instead. - Catching and doing nothing — an empty
catch {}hides the bug and the data at once; at minimum logmessageOf(err). - Using a Result for programmer bugs — returning
{ ok: false }for an impossible state just moves the crash; throw and fix the caller. - Assertions inside the catch —
(err as Error).messagecompiles and then throws on the one failure that mattered; useinstanceof. - Forgetting the default branch — without
assertNeveror an exhaustive check, adding a union member silently skips your switch.
Practice checklist
- Write
messageOf(err: unknown)and prove it handles anError, a string and a plain object. - Define
Result<T, E>from memory, then implementsafeDivide(a: number, b: number): Result<number, string>. - Refactor a function that throws into one that returns a Result, and list in prose which callers should switch from one style to the other.
- Add a member to a
Statusunion and observe every non-exhaustive switch report TS2345. - Build an
HttpErrorsubclass, throw it, and confirminstanceof HttpErrornarrows the caught value in acatchblock.
What's next?
Failure now has a type. Next you prove the types behave, with tests that are themselves checked by the compiler: Testing with TypeScript.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.