saferror
v1.0.1
Published
Type-safe error handling for TypeScript: structured errors, safe try/catch, and serialization.
Downloads
84
Maintainers
Readme
saferror
Type-safe, structured error handling for TypeScript. Stop catching
unknown, start catching what you actually expect.
Why saferror?
JavaScript's native try/catch forces you to work with unknown — there's no type information, no structured context, and no consistent shape. Errors thrown from third-party libraries, async operations, or your own code all look the same at the catch site.
saferror gives you:
- Typed errors with a consistent shape —
message,uid,statusCode,info,cause safeTry— a zero-overhead wrapper that replacestry/catchwith an[error, result]tuple, fully typedserializeError— turn any error (native or custom) into a plain object safe for logging and JSON serialization- No thrown errors in your application logic — if you want them, they're opt-in
Installation
npm install saferroryarn add saferrorpnpm add saferrorRequirements: Node.js ≥ 23.0.0, TypeScript ≥ 5.0.
Quick Start
import { safeTry, CustomError, UnknownError, serializeError, isCustomError } from "saferror";
// 1. Define your typed errors
class DatabaseError extends CustomError {
override statusCode = 503;
}
class NotFoundError extends CustomError {
override statusCode = 404;
}
// 2. Use safeTry instead of try/catch — never throws, always returns a tuple
const [err, user] = await safeTry(
() => db.findUser(id),
(cause) => new DatabaseError({ message: "Failed to fetch user", cause, info: { userId: id } }),
);
if (err) {
console.error(serializeError(err)); // structured log-ready object
return;
}
console.log(user); // typed, non-null ✓API Reference
CustomError (abstract class)
The base class for all your application errors. Extend it to create typed, structured errors.
abstract class CustomError<T extends Record<string, unknown> = Record<string, unknown>> extends Error {
uid: string; // Auto-generated UUID (crypto.randomUUID)
statusCode: number; // Default: 400. Override per subclass.
info: T; // Arbitrary structured metadata
cause?: Error; // Native ES2022 cause chain
}Defining custom errors
import { CustomError } from "saferror";
// Simple error
class PaymentError extends CustomError {
override statusCode = 402;
}
// Typed info metadata
interface ValidationInfo {
field: string;
rule: string;
received: unknown;
}
class ValidationError extends CustomError<ValidationInfo> {
override statusCode = 422;
}
// Usage
throw new ValidationError({
message: "Invalid email address",
info: { field: "email", rule: "format", received: "not-an-email" },
});Constructor options
| Option | Type | Required | Description |
|-----------|----------|----------|--------------------------------------------------|
| message | string | ✅ | Human-readable error description |
| info | T | ❌ | Structured metadata. Defaults to {} |
| cause | Error | ❌ | The underlying error that triggered this one |
toString()
Produces a multi-line human-readable string including name, message, uid, info, and the full cause chain:
ValidationError: Invalid email address [uid=3f1a9c2d-...]
info: {"field":"email","rule":"format","received":"not-an-email"}
cause: Original parse error
Error: Original parse error
at ...safeTry
A universal async/sync wrapper that never throws. Returns an [error, result] tuple.
function safeTry<T, E extends CustomError = CustomError>(
fn: () => T | Promise<T>,
errorFactory?: (cause?: Error) => E,
): Result<T, E>The tuple shape:
[null, T]on success — error is null, result is typed[E]on failure — result is undefined, error is typed
Without a factory — wraps in UnknownError
const [err, parsed] = await safeTry(() => JSON.parse(rawInput));
if (err) {
// err is UnknownError (a CustomError), never plain Error
console.log(err.message); // "Unexpected token ..."
return;
}
console.log(parsed); // typed as the return type of JSON.parseWith a factory — wraps in your typed error
class AiError extends CustomError {
override statusCode = 500;
}
const [err, response] = await safeTry(
() => openai.chat.completions.create(payload),
(cause) => new AiError({ message: "AI generation failed", cause }),
);Sync functions work too
const [err, result] = await safeTry(() => riskySync());CustomErrors pass through untouched
If the function throws a CustomError, safeTry returns it as-is without re-wrapping, even if a factory is provided:
const original = new PaymentError({ message: "Insufficient funds" });
const [err] = await safeTry(
() => { throw original; },
() => new DatabaseError({ message: "won't be used" }),
);
err === original; // true — passed through directlyNon-Error throws are normalized
const [err] = await safeTry(() => { throw "something went wrong"; });
err?.message; // "something went wrong" — string is wrapped in Error firstResult<T, E> type
The return type of safeTry. A Promise resolving to either a success or an error tuple.
type Result<T = null, E extends CustomError = CustomError> = Promise<[E] | [null, T]>;Use it to type functions that return safe results:
import { type Result, CustomError } from "saferror";
class FetchError extends CustomError {}
async function fetchUser(id: string): Result<User, FetchError> {
return safeTry(
() => api.getUser(id),
(cause) => new FetchError({ message: "User fetch failed", cause, info: { id } }),
);
}
// Call site — destructured, fully typed
const [err, user] = await fetchUser("user_123");serializeError
Converts any error value into a plain object safe for structured logging (e.g. Pino, Winston, Datadog).
function serializeError(err: unknown): Record<string, unknown>| Input type | Output fields |
|----------------|-------------------------------------------------------------------|
| CustomError | name, message, uid, info, stack, cause (if present) |
| Error | name, message, stack |
| anything else | { raw: String(err) } |
import { serializeError } from "saferror";
const [err] = await safeTry(() => db.query(sql));
if (err) {
logger.error(serializeError(err));
// {
// name: "DatabaseError",
// message: "Connection refused",
// uid: "a1b2c3d4-...",
// info: { host: "localhost", port: 5432 },
// stack: "DatabaseError: Connection refused\n at ...",
// cause: { name: "Error", message: "ECONNREFUSED", stack: "..." }
// }
}isCustomError
Type guard to check if a value is a CustomError instance (or any subclass).
function isCustomError(err: unknown): err is CustomErrortry {
riskyThirdPartyCall();
} catch (err) {
if (isCustomError(err)) {
// err is CustomError — access .uid, .info, .statusCode safely
console.log(err.uid);
} else {
// handle plain Error or unknown
}
}UnknownError
The concrete CustomError subclass used internally by safeTry when no factory is provided. You can also use it directly for errors of uncertain origin.
class UnknownError extends CustomError {
constructor(options: CustomErrorOptions) {
super(options);
}
}Patterns & Recipes
Pattern: Service layer with typed results
// services/user.service.ts
import { safeTry, type Result, CustomError } from "saferror";
export class UserNotFoundError extends CustomError {
override statusCode = 404;
}
export class UserDbError extends CustomError {
override statusCode = 503;
}
export async function getUser(id: string): Result<User, UserNotFoundError | UserDbError> {
const [dbErr, row] = await safeTry(
() => db.users.findUnique({ where: { id } }),
(cause) => new UserDbError({ message: "DB read failed", cause, info: { id } }),
);
if (dbErr) return [dbErr];
if (!row) return [new UserNotFoundError({ message: `User ${id} not found`, info: { id } })];
return [null, row];
}// handlers/user.handler.ts
const [err, user] = await getUser(req.params.id);
if (err) {
res.status(err.statusCode).json({ error: err.message, ref: err.uid });
return;
}
res.json(user);Pattern: Chaining errors with cause
class ConfigError extends CustomError { override statusCode = 500; }
class AppStartupError extends CustomError { override statusCode = 500; }
const [configErr, config] = await safeTry(
() => loadConfig("./config.json"),
(cause) => new ConfigError({ message: "Failed to load config", cause }),
);
if (configErr) {
throw new AppStartupError({
message: "Application failed to start",
cause: configErr, // chain the original error
info: { phase: "init" },
});
}Pattern: Logging every error uniformly
import { serializeError, isCustomError } from "saferror";
function logError(err: unknown, context?: Record<string, unknown>) {
const serialized = serializeError(err);
logger.error({
...serialized,
...context,
// uid is present for CustomErrors — use it to correlate logs with user-facing error refs
ref: isCustomError(err) ? err.uid : undefined,
});
}Pattern: Express error middleware
import { isCustomError, serializeError } from "saferror";
import type { Request, Response, NextFunction } from "express";
export function errorMiddleware(err: unknown, req: Request, res: Response, _next: NextFunction) {
logger.error(serializeError(err));
if (isCustomError(err)) {
res.status(err.statusCode).json({
error: err.message,
ref: err.uid, // Return uid so users can report issues
});
return;
}
res.status(500).json({ error: "Internal server error" });
}TypeScript Notes
- All exports are fully typed with generic type parameters
CustomError<T>is generic over theinfoshape — get autocomplete on.infofieldsResult<T, E>propagates your error type through the entire call chainisCustomErroris a proper type guard — TypeScript narrows the type after theifblocksafeTryinfers the return type offnautomatically — no manual annotation needed
Contributing
Contributions are welcome! Here's how to get started:
git clone https://github.com/andyguillaume/saferror.git
cd saferror
npm installDevelopment workflow
npm test # run tests once
npm run test:watch # run tests in watch mode
npm run typecheck # TypeScript type check (no emit)
npm run build # build to dist/Submitting changes
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Write tests first (TDD) — tests live in
src/__tests__/ - Implement your change
- Run
npm testandnpm run typecheck— both must pass - Open a pull request against
main
Reporting bugs
Please open an issue at github.com/andyguillaume/saferror/issues and include:
- Node.js version (
node --version) - TypeScript version (
tsc --version) - A minimal reproduction
Changelog
See GitHub Releases.
