A small learning project for typed error handling in TypeScript.
It compares three approaches:
- normal
throwandtry/catch - plain TypeScript result unions
neverthrow
It also shows one key benefit of typed errors:
When you add a new error type, TypeScript can show all places that now need to be updated.
This project helps you understand four things:
- what is weak about normal exception-based error handling
- how plain TypeScript can make errors more explicit
- how
neverthrowreduces some manual boilerplate - how exhaustive type checks help when error types change
The project stays small on purpose.
There are only a few error types:
InvalidInputNetworkErrorParseError
Then one more error is added later:
UnauthorizedError
That extra error is used to show how type checking catches missing handling.
This shows the common style many projects use.
It uses:
throwtry/catch- generic runtime errors
Main issue:
- error flow is hidden
- function signatures do not show which errors can happen
- adding new error cases does not force updates in all callers
This shows a better baseline without any library.
It uses:
- a custom
Result<T, E> - tagged error objects
- explicit success and failure values
Main benefit:
- errors are visible in return types
- callers must check success vs failure
Main cost:
- more manual boilerplate
This shows the same idea with a library built for Result handling.
It uses:
ResultResultAsync- chaining methods like
andThen,asyncAndThen, etc.
Main benefit:
- cleaner composition
- less manual branching in async flows
This is the most important part.
It shows what happens when a new error type is added.
Expected result:
- type checking fails in places that do not handle the new error yet
- once you add the missing case, type checking passes again
Shared building blocks used by all examples.
Common app types.
Example:
- API data shape
- UI/demo input modes
All tagged error object types live here.
This is the main place to inspect the error model.
Helper for exhaustive checks.
This is what makes missing switch cases fail during type checking.
Small fake async API.
It simulates:
- success
- network failure
- bad JSON
Example using throw and try/catch.
Shows the normal exception-based flow.
Use this folder to understand the current pain:
- hidden error paths
- runtime-only handling
- weak guarantees
Example using plain TypeScript result unions.
Small custom Result<T, E> type.
Same user flow as the throw version, but with explicit return types.
Use this folder to understand:
- how far plain TypeScript already gets you
- where manual boilerplate starts growing
Example using neverthrow.
Same user flow again, now with neverthrow.
Use this folder to compare:
- less branching
- cleaner async composition
- same core idea as the TS union version
Files for the type-checking demo when a new error is added.
Current working exhaustive switch.
This file handles the original error union.
Intentional failing example.
This file uses an extended error union but does not handle the new error yet.
Use this folder to see the key lesson:
- add one new error
- type check fails
- fix the missing case
- type check passes again
Small UI helpers for the demo page.
Renders the input, buttons, and output area.
App entry file.
It wires the buttons to each example.
Install these first:
- Node.js 24
- npm
- Git
Use a recent Node.js version. If your Node version is too old, Vite may fail to start.
git clone https://github.com/palashmon/learn-typed-errors.git cd learn-typed-errorsnpm installnpm run devVite prints a local URL in the terminal.
It usually looks like this:
http://localhost:5173/ Open that in your browser.
Run this any time you want to check the TypeScript state:
npm run typecheckExpected result at the start:
- no errors
Start the app:
npm run devOpen the page in the browser.
You will see:
- an input box
- buttons for each example
- an output area
Click:
Throw: successThrow: invalid inputThrow: network error
What to notice:
- errors become plain runtime messages
- the function signature does not clearly show all possible errors
Click:
TS union: parse error
What to notice:
- success and failure are explicit
- error handling is more visible
- there is more manual checking
Click:
neverthrow: successneverthrow: invalid inputneverthrow: network error
What to notice:
- same error model
- cleaner flow than manual result handling
- less repeated branching
Click:
Exhaustive switch demo
What to notice:
- shows message
Bad response: JSON parse failed
This is the main learning step.
Run:
npm run typecheckExpected result:
- no errors
src/add-error-demo/after-add-error.ts This file uses ExtendedAppError.
That union includes:
InvalidInputNetworkErrorParseErrorUnauthorizedError
But the switch in that file does not handle UnauthorizedError yet.
In src/main.ts, switch usage from:
formatInitialError(...)to:
formatExtendedError(...)You can do this by importing formatExtendedError from:
src/add-error-demo/after-add-error.ts and using it instead of formatInitialError.
npm run typecheckExpected result:
- TypeScript fails
- the error points to the missing
UnauthorizedErrorhandling
This is the key benefit.
When the error union changes, the compiler helps you find missing updates.
In src/add-error-demo/after-add-error.ts, add this case:
case "UnauthorizedError": return `Login required: ${error.message}`;npm run typecheckExpected result:
- no errors
This confirms the exhaustive handler is complete again.
This demo shows the opposite situation.
Instead of adding a new error, we remove an existing error from the union.
TypeScript will then show all places where that error is still handled but no longer possible.
This helps remove dead code safely.
Run:
npm run typecheckExpected result:
- no errors
Open this file:
src/shared/errors.ts Find the AppError union.
Example before:
export type AppError = InvalidInputError | NetworkError | ParseErrorNow remove one error. For this demo, remove ParseError.
Example after:
export type AppError = InvalidInputError | NetworkErrorSave the file.
npm run typecheckExpected result:
- TypeScript now fails in several places.
The errors usually appear in switch statements or error handlers that still include:
case "ParseError":Go to each location TypeScript reports.
Delete the unused case.
Example before:
case "ParseError": return `Bad response: ${error.message}`;Example after:
// removed because ParseError is no longer part of AppErrorRepeat this for all reported locations.
npm run typecheckExpected result:
- no errors
This confirms that all code paths referencing the removed error are gone.
Typed error unions help in two directions:
When you add a new error
- TypeScript shows where handling is missing.
When you remove an error
- TypeScript shows where dead code still exists.
This keeps error handling consistent as the system evolves.