@nakanoaas/tagged-error
v2.0.1
Published
A type-safe error handling solution without custom error classes.
Readme
Tagged Error
Type-safe error handling for TypeScript — return errors instead of throwing them.
The Problem with try/catch in TypeScript
In TypeScript, try/catch has a fundamental limitation: the caught value is
always typed as unknown.
try {
const data = await fetchUser(id);
} catch (e) {
// e is `unknown` — TypeScript has no idea what was thrown
console.error(e.message); // Error: Object is of type 'unknown'
}This means:
- You must cast or narrow
emanually before using it - The function signature gives no hint about what errors it might produce
- Callers have no way to know what to handle — unless they read the source
Even with custom error classes, throw cannot surface error types to callers.
The errors a function may throw are invisible to its type signature.
The Idea: Return Errors, Don't Throw Them
Tagged Error takes a different approach, inspired by Rust's Result<T, E>
pattern: treat errors as return values.
When a function returns a TaggedError, it becomes part of the function's
return type. TypeScript can now see — and enforce — every possible outcome.
// Return type is inferred as `User | TaggedError<"USER_NOT_FOUND">`
function findUser(id: string) {
const user = db.users.findById(id);
if (!user) {
return new TaggedError("USER_NOT_FOUND", {
message: `No user with id "${id}"`,
cause: { id },
});
}
return user;
}The caller uses instanceof to narrow the type — TypeScript handles the rest:
const result = findUser("u_123");
if (result instanceof Error) {
// result is TaggedError<"USER_NOT_FOUND"> — fully typed
console.error(result.message);
console.error("Searched for id:", result.cause.id); // typed as string
return;
}
// result is User here
console.log(result.name);No try/catch. No type casting. No guessing.
Quick Start
import { TaggedError } from "@nakanoaas/tagged-error";Step 1 — Define a function that returns errors
function login(username: string, password: string) {
const user = db.users.findByUsername(username);
if (!user) {
return new TaggedError("USER_NOT_FOUND", {
message: `No account found for "${username}"`,
});
}
if (user.lockedUntil && user.lockedUntil > new Date()) {
return new TaggedError("ACCOUNT_LOCKED", {
message: "Account is temporarily locked",
cause: { lockedUntil: user.lockedUntil },
});
}
if (!verifyPassword(password, user.passwordHash)) {
return new TaggedError("WRONG_PASSWORD", {
message: "Incorrect password",
cause: { attemptsRemaining: user.maxAttempts - user.failedAttempts - 1 },
});
}
return { userId: user.id, token: generateToken(user) };
}Step 2 — Handle the result with full type safety
const result = login("alice", "hunter2");
if (result instanceof Error) {
switch (result.tag) {
case "USER_NOT_FOUND":
console.error(result.message);
break;
case "ACCOUNT_LOCKED":
// result.cause.lockedUntil is typed as Date
console.error(
`Try again after ${result.cause.lockedUntil.toLocaleString()}`,
);
break;
case "WRONG_PASSWORD":
// result.cause.attemptsRemaining is typed as number
console.error(`${result.cause.attemptsRemaining} attempts remaining`);
break;
}
return;
}
// result is typed as { userId: string; token: string }
console.log("Logged in:", result.userId);TypeScript infers the union return type automatically. If you forget to handle an error case, the compiler will tell you.
Features
- Type-safe: Every error appears in the return type — no hidden throws
- No boilerplate: No need to define custom error classes
- Structured: Attach typed context data via
cause - Lightweight: Zero dependencies, ~100 lines of source
- Compatible: Extends native
Error— works with existing tooling
Installation
Requires ES2022 or later.
npm install @nakanoaas/tagged-error # npm
pnpm add @nakanoaas/tagged-error # pnpm
yarn add @nakanoaas/tagged-error # yarnFor Deno users (ESM only):
deno add jsr:@nakanoaas/tagged-error # deno
npx jsr add @nakanoaas/tagged-error # npm
pnpm dlx jsr add @nakanoaas/tagged-error # pnpmAPI Reference
TaggedError<Tag, Cause>
new TaggedError(tag: string, options?: {
message?: string;
cause?: any;
})Parameters
tag: A string literal that identifies the error type (stored as areadonlyproperty)options: Optional configuration objectmessage: Human-readable error message (non-enumerable, matching nativeErrorbehavior)cause: Additional error context data (non-enumerable, matching nativeErrorbehavior)
Properties
| Property | Type | Enumerable | Description |
| --------- | -------- | ---------- | --------------------------------------------------- |
| tag | Tag | Yes | The string literal passed at construction |
| cause | Cause | No | Context data; excluded from JSON.stringify |
| name | string | No | Computed as TaggedError(TAG) via prototype getter |
| message | string | No | Inherited from Error |
| stack | string | No | Inherited from Error |
JSON.stringify will only include tag:
const err = new TaggedError("MY_TAG", { cause: { value: 42 } });
JSON.stringify(err); // '{"tag":"MY_TAG"}'
err.cause.value; // 42Migrating to v2
error.name format changed
The tag is no longer wrapped in single quotes, and name is now a
non-enumerable prototype getter.
// v1
error.name === "TaggedError('MY_TAG')";
// v2
error.name === "TaggedError(MY_TAG)";cause and name are now non-enumerable
Both cause and name now behave like native Error properties — they will
not appear in JSON.stringify or object spread.
const err = new TaggedError("MY_TAG", { cause: { value: 42 } });
// v1: {"tag":"MY_TAG","name":"TaggedError('MY_TAG')","cause":{"value":42}}
// v2: {"tag":"MY_TAG"}
JSON.stringify(err);
// Access cause directly as before — no change needed
err.cause.value; // 42tag is now readonly
Assigning to tag after construction is now a compile-time error.
ES2022 or later is required
Ensure your tsconfig.json targets ES2022 or later:
{ "compilerOptions": { "target": "ES2022" } }License
MIT © Nakano as a Service
