Type-safe error handling for TypeScript — return errors instead of throwing them.
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.
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.
import { TaggedError } from "@nakanoaas/tagged-error";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) };
}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.
- 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
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 # pnpmnew TaggedError(tag: string, options?: {
message?: string;
cause?: any;
})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)
| 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; // 42The 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)";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; // 42Assigning to tag after construction is now a compile-time error.
Ensure your tsconfig.json targets ES2022 or later:
{ "compilerOptions": { "target": "ES2022" } }MIT © Nakano as a Service