11# Tagged Error
22
3- 🏷️ Type-safe error handling in TypeScript without the hassle of custom error
4- classes .
3+ Type-safe error handling for TypeScript — return errors instead of throwing
4+ them .
55
66[ ![ npm version] ( https://badge.fury.io/js/@nakanoaas%2Ftagged-error.svg )] ( https://www.npmjs.com/package/@nakanoaas/tagged-error )
77[ ![ JSR Version] ( https://jsr.io/badges/@nakanoaas/tagged-error )] ( https://jsr.io/@nakanoaas/tagged-error )
88[ ![ License: MIT] ( https://img.shields.io/badge/License-MIT-yellow.svg )] ( https://opensource.org/licenses/MIT )
99
10- ## Features
10+ ## The Problem with try/catch in TypeScript
1111
12- - 🎯 ** Type-safe** : Full TypeScript support with type inference
13- - 🪶 ** Lightweight** : Zero dependencies, minimal code
14- - 🔍 ** Easy debugging** : Clear error messages with structured data
15- - 💡 ** Simple API** : No need to create custom error classes
12+ In TypeScript, ` try/catch ` has a fundamental limitation: ** the caught value is
13+ always typed as ` unknown ` ** .
1614
17- ## Installation
15+ ``` typescript
16+ try {
17+ const data = await fetchUser (id );
18+ } catch (e ) {
19+ // e is `unknown` — TypeScript has no idea what was thrown
20+ console .error (e .message ); // Error: Object is of type 'unknown'
21+ }
22+ ```
1823
19- Choose your preferred package manager :
24+ This means :
2025
21- ``` bash
22- npm install @nakanoaas/tagged-error # npm
23- pnpm add @nakanoaas/tagged-error # pnpm
24- yarn add @nakanoaas/tagged-error # yarn
26+ - You must cast or narrow ` e ` manually before using it
27+ - The function signature gives no hint about what errors it might produce
28+ - Callers have no way to know what to handle — unless they read the source
29+
30+ Even with custom error classes, ` throw ` cannot surface error types to callers.
31+ The errors a function may throw are invisible to its type signature.
32+
33+ ## The Idea: Return Errors, Don't Throw Them
34+
35+ Tagged Error takes a different approach, inspired by Rust's ` Result<T, E> `
36+ pattern: ** treat errors as return values** .
37+
38+ When a function returns a ` TaggedError ` , it becomes part of the function's
39+ return type. TypeScript can now see — and enforce — every possible outcome.
40+
41+ ``` typescript
42+ // Return type is inferred as `User | TaggedError<"USER_NOT_FOUND">`
43+ function findUser(id : string ) {
44+ const user = db .users .findById (id );
45+ if (! user ) {
46+ return new TaggedError (" USER_NOT_FOUND" , {
47+ message: ` No user with id "${id }" ` ,
48+ cause: { id },
49+ });
50+ }
51+ return user ;
52+ }
2553```
2654
27- For Deno users (ESM only) :
55+ The caller uses ` instanceof ` to narrow the type — TypeScript handles the rest :
2856
29- ``` bash
30- deno add jsr:@nakanoaas/tagged-error # deno
31- npx jsr add @nakanoaas/tagged-error # npm
32- pnpm dlx jsr add @nakanoaas/tagged-error # pnpm
57+ ``` typescript
58+ const result = findUser (" u_123" );
59+
60+ if (result instanceof Error ) {
61+ // result is TaggedError<"USER_NOT_FOUND"> — fully typed
62+ console .error (result .message );
63+ console .error (" Searched for id:" , result .cause .id ); // typed as string
64+ return ;
65+ }
66+
67+ // result is User here
68+ console .log (result .name );
3369```
3470
71+ No ` try/catch ` . No type casting. No guessing.
72+
3573## Quick Start
3674
3775``` typescript
3876import { TaggedError } from " @nakanoaas/tagged-error" ;
77+ ```
78+
79+ ### Step 1 — Define a function that returns errors
80+
81+ ``` typescript
82+ function login(username : string , password : string ) {
83+ const user = db .users .findByUsername (username );
3984
40- // Example: A function that might fail in different ways
41- function divideAndSquareRoot(num : number , divisor : number ) {
42- if (divisor === 0 ) {
43- return new TaggedError (" DIVISOR_IS_ZERO" , {
44- message: " Cannot divide by zero" ,
85+ if (! user ) {
86+ return new TaggedError (" USER_NOT_FOUND" , {
87+ message: ` No account found for "${username }" ` ,
4588 });
4689 }
4790
48- const result = num / divisor ;
91+ if (user .lockedUntil && user .lockedUntil > new Date ()) {
92+ return new TaggedError (" ACCOUNT_LOCKED" , {
93+ message: " Account is temporarily locked" ,
94+ cause: { lockedUntil: user .lockedUntil },
95+ });
96+ }
4997
50- if (result < 0 ) {
51- return new TaggedError (" NEGATIVE_RESULT " , {
52- message: " Cannot calculate square root of negative number " ,
53- cause: { value: result },
98+ if (! verifyPassword ( password , user . passwordHash ) ) {
99+ return new TaggedError (" WRONG_PASSWORD " , {
100+ message: " Incorrect password " ,
101+ cause: { attemptsRemaining: user . maxAttempts - user . failedAttempts - 1 },
54102 });
55103 }
56104
57- return Math . sqrt ( result ) ;
105+ return { userId: user . id , token: generateToken ( user ) } ;
58106}
107+ ```
59108
60- // Using the function
61- const result = divideAndSquareRoot (10 , 0 );
109+ ### Step 2 — Handle the result with full type safety
110+
111+ ``` typescript
112+ const result = login (" alice" , " hunter2" );
62113
63- // Type-safe error handling
64- if (result instanceof TaggedError ) {
114+ if (result instanceof Error ) {
65115 switch (result .tag ) {
66- case " DIVISOR_IS_ZERO " :
67- console .error (" Division by zero error: " , result .message );
116+ case " USER_NOT_FOUND " :
117+ console .error (result .message );
68118 break ;
69- case " NEGATIVE_RESULT" :
119+ case " ACCOUNT_LOCKED" :
120+ // result.cause.lockedUntil is typed as Date
70121 console .error (
71- " Negative result error:" ,
72- result .message ,
73- " Value:" ,
74- result .cause .value ,
122+ ` Try again after ${result .cause .lockedUntil .toLocaleString ()} ` ,
75123 );
76124 break ;
125+ case " WRONG_PASSWORD" :
126+ // result.cause.attemptsRemaining is typed as number
127+ console .error (` ${result .cause .attemptsRemaining } attempts remaining ` );
128+ break ;
77129 }
78- } else {
79- console .log (" Result:" , result ); // result is typed as number
130+ return ;
80131}
132+
133+ // result is typed as { userId: string; token: string }
134+ console .log (" Logged in:" , result .userId );
81135```
82136
83- ## Why Tagged Error?
137+ TypeScript infers the union return type automatically. If you forget to handle
138+ an error case, the compiler will tell you.
84139
85- Traditional error handling in TypeScript often involves creating multiple error
86- classes or using string literals. Tagged Error provides a simpler approach:
140+ ## Features
87141
88- ``` typescript
89- // ❌ Traditional approach - lots of boilerplate
90- class DivisorZeroError extends Error {
91- constructor () {
92- super (" Cannot divide by zero" );
93- }
94- }
142+ - ** Type-safe** : Every error appears in the return type — no hidden throws
143+ - ** No boilerplate** : No need to define custom error classes
144+ - ** Structured** : Attach typed context data via ` cause `
145+ - ** Lightweight** : Zero dependencies, ~ 100 lines of source
146+ - ** Compatible** : Extends native ` Error ` — works with existing tooling
147+
148+ ## Installation
149+
150+ Requires ES2022 or later.
151+
152+ ``` bash
153+ npm install @nakanoaas/tagged-error # npm
154+ pnpm add @nakanoaas/tagged-error # pnpm
155+ yarn add @nakanoaas/tagged-error # yarn
156+ ```
157+
158+ For Deno users (ESM only):
95159
96- // ✅ Tagged Error approach - clean and type-safe
97- return new TaggedError ( " DIVISOR_IS_ZERO " , {
98- message: " Cannot divide by zero " ,
99- });
160+ ``` bash
161+ deno add jsr:@nakanoaas/tagged-error # deno
162+ npx jsr add @nakanoaas/tagged-error # npm
163+ pnpm dlx jsr add @nakanoaas/tagged-error # pnpm
100164```
101165
102166## API Reference
@@ -112,10 +176,74 @@ new TaggedError(tag: string, options?: {
112176
113177#### Parameters
114178
115- - ` tag ` : A string literal that identifies the error type
179+ - ` tag ` : A string literal that identifies the error type (stored as a ` readonly `
180+ property)
116181- ` options ` : Optional configuration object
117- - ` message ` : Human-readable error message
118- - ` cause ` : Additional error context data
182+ - ` message ` : Human-readable error message (non-enumerable, matching native
183+ ` Error ` behavior)
184+ - ` cause ` : Additional error context data (non-enumerable, matching native
185+ ` Error ` behavior)
186+
187+ #### Properties
188+
189+ | Property | Type | Enumerable | Description |
190+ | --------- | -------- | ---------- | --------------------------------------------------- |
191+ | ` tag ` | ` Tag ` | Yes | The string literal passed at construction |
192+ | ` cause ` | ` Cause ` | No | Context data; excluded from ` JSON.stringify ` |
193+ | ` name ` | ` string ` | No | Computed as ` TaggedError(TAG) ` via prototype getter |
194+ | ` message ` | ` string ` | No | Inherited from ` Error ` |
195+ | ` stack ` | ` string ` | No | Inherited from ` Error ` |
196+
197+ ` JSON.stringify ` will only include ` tag ` :
198+
199+ ``` typescript
200+ const err = new TaggedError (" MY_TAG" , { cause: { value: 42 } });
201+ JSON .stringify (err ); // '{"tag":"MY_TAG"}'
202+ err .cause .value ; // 42
203+ ```
204+
205+ ## Migrating to v2
206+
207+ ### ` error.name ` format changed
208+
209+ The tag is no longer wrapped in single quotes, and ` name ` is now a
210+ non-enumerable prototype getter.
211+
212+ ``` ts
213+ // v1
214+ error .name === " TaggedError('MY_TAG')" ;
215+
216+ // v2
217+ error .name === " TaggedError(MY_TAG)" ;
218+ ```
219+
220+ ### ` cause ` and ` name ` are now non-enumerable
221+
222+ Both ` cause ` and ` name ` now behave like native ` Error ` properties — they will
223+ not appear in ` JSON.stringify ` or object spread.
224+
225+ ``` ts
226+ const err = new TaggedError (" MY_TAG" , { cause: { value: 42 } });
227+
228+ // v1: {"tag":"MY_TAG","name":"TaggedError('MY_TAG')","cause":{"value":42}}
229+ // v2: {"tag":"MY_TAG"}
230+ JSON .stringify (err );
231+
232+ // Access cause directly as before — no change needed
233+ err .cause .value ; // 42
234+ ```
235+
236+ ### ` tag ` is now ` readonly `
237+
238+ Assigning to ` tag ` after construction is now a compile-time error.
239+
240+ ### ES2022 or later is required
241+
242+ Ensure your ` tsconfig.json ` targets ES2022 or later:
243+
244+ ``` json
245+ { "compilerOptions" : { "target" : " ES2022" } }
246+ ```
119247
120248## License
121249
0 commit comments