Skip to content

Commit e424432

Browse files
Merge pull request #18 from nakanoasaservice/feature/v2
feat!: align with standard Error behavior and improve ergonomics (v2)
2 parents 4bd5871 + 22a9fac commit e424432

File tree

5 files changed

+318
-105
lines changed

5 files changed

+318
-105
lines changed

README.md

Lines changed: 187 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,166 @@
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
3876
import { 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

deno.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "1.0.3",
2+
"version": "2.0.0",
33
"name": "@nakanoaas/tagged-error",
44
"license": "MIT",
55
"exports": "./index.ts",
@@ -18,11 +18,13 @@
1818
"noUncheckedIndexedAccess": true,
1919
"noImplicitReturns": true,
2020
"noFallthroughCasesInSwitch": true,
21+
"exactOptionalPropertyTypes": true,
22+
"useDefineForClassFields": true,
2123
"lib": ["deno.ns", "es2022"]
2224
},
2325
"imports": {
2426
"@deno/dnt": "jsr:@deno/dnt@^0.42.3",
25-
"@std/assert": "jsr:@std/assert@^1.0.16",
26-
"@std/testing": "jsr:@std/testing@^1.0.16"
27+
"@std/assert": "jsr:@std/assert@^1.0.19",
28+
"@std/testing": "jsr:@std/testing@^1.0.17"
2729
}
2830
}

0 commit comments

Comments
 (0)