UNPKG

@fajarnugraha37/error

Version:

Runtime-agnostic error handling library with structured errors, adapters, and validation support for Bun, Node.js, and browsers

614 lines (449 loc) 15.6 kB
# @fajarnugraha37/error > Runtime-agnostic error handling library with structured errors, adapters, and validation support for Bun, Node.js, and browsers. [![npm version](https://badge.fury.io/js/@fajarnugraha37%2Ferror.svg)](https://www.npmjs.com/package/@fajarnugraha37/error) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ## Features ✅ **Runtime Agnostic** – Works seamlessly on Bun, Node.js (18+), and modern browsers ✅ **Zero Dependencies** – Core library has no dependencies ✅ **Tree-Shakable** – Only bundle what you use ✅ **Small Bundle** – < 3.5 KB gzipped for core ✅ **Type-Safe** – Full TypeScript support with strict types ✅ **Rich Metadata** – Error codes, severity, status, tags, data, and more ✅ **RFC 9457** – Problem Details for HTTP APIs support ✅ **Validation** – Built-in validation error handling with Zod, TypeBox, AJV adapters ✅ **Result Type** – Functional error handling with `Result<T, E>` ✅ **Framework Adapters** – Express, Fastify, Koa, Hono, H3, Elysia, GraphQL ✅ **Observability** – OpenTelemetry and Sentry bridges ✅ **Security** – Built-in redaction for sensitive data ✅ **Stack Normalization** – Consistent stack traces across runtimes --- ## Quick Start ### Installation ```bash # Bun bun add @fajarnugraha37/error # npm npm install @fajarnugraha37/error # pnpm pnpm add @fajarnugraha37/error # yarn yarn add @fajarnugraha37/error ``` ### 60-Second Examples #### Bun + Hono ```typescript import { Hono } from "hono"; import { error } from "@fajarnugraha37/error"; import { honoErrorHandler } from "@fajarnugraha37/error/adapters/hono"; const app = new Hono(); app.get("/boom", () => { throw error("db/timeout", "Database connection timed out", { status: 503, retryable: true, data: { host: "db.example.com" }, }); }); app.onError(honoErrorHandler()); export default app; ``` #### Node.js + Express ```typescript import express from "express"; import { wrap } from "@fajarnugraha37/error"; import { expressErrorHandler } from "@fajarnugraha37/error/adapters/express"; const app = express(); app.get("/users/:id", async (req, res, next) => { try { const user = await getUser(req.params.id); res.json(user); } catch (e) { next(wrap(e, "user/fetch-failed", { status: 500 })); } }); app.use(expressErrorHandler()); ``` #### Browser + Fetch ```typescript import { error } from "@fajarnugraha37/error"; import { toResponse } from "@fajarnugraha37/error/adapters/web"; async function handleRequest(request: Request): Promise<Response> { const token = request.headers.get("Authorization"); if (!token) { const err = error("auth/missing-token", "Authorization token required", { status: 401, }); return toResponse(err); } // ... rest of logic } ``` --- ## Core API ### AppError ```typescript import { error, wrap, fromUnknown, isAppError } from "@fajarnugraha37/error"; // Create structured error const err = error("payment/declined", "Card was declined", { status: 402, severity: "error", retryable: true, tags: ["payment", "stripe"], data: { cardLast4: "1234", code: "card_declined" }, }); // Wrap existing errors try { await riskyOperation(); } catch (e) { throw wrap(e, "operation/failed", { status: 500 }); } // Convert unknown to AppError const appErr = fromUnknown(unknownError); // Type guard if (isAppError(err)) { console.log(err.code); // "payment/declined" } ``` ### AppError Fields | Field | Type | Description | | ----------- | ------------------------------------ | ------------------------------------ | | `code` | `string` | Machine-readable error code | | `message` | `string` | Human-readable error message | | `id` | `string` | UUID v7 (time-ordered) | | `timestamp` | `number` | Unix timestamp (ms) | | `severity` | `'info' \| 'warn' \| 'error' \| 'fatal'` | Error severity level | | `status` | `number?` | HTTP status code | | `retryable` | `boolean` | Whether operation can be retried | | `tags` | `string[]` | Tags for categorization/filtering | | `data` | `unknown?` | Additional error context | | `cause` | `unknown?` | Original error (for wrapping) | | `stack` | `string?` | Normalized stack trace | ### Result Type ```typescript import { ok, err, safeAwait, unwrap } from "@fajarnugraha37/error"; // Manual result creation const success = ok(42); const failure = err(error("calculation/failed")); // Safe async operations async function fetchUser(id: string) { const [err, user] = await safeAwait(fetch(`/users/${id}`)); if (err) { console.error("Failed to fetch user:", err.message); return null; } return user; } // Unwrap or throw const value = unwrap(result); // throws if error ``` ### Assert ```typescript import { assert } from "@fajarnugraha37/error"; // Throws AppError if condition is false assert.ok(user.age >= 18, "user/underage", "User must be 18 or older"); assert.defined(config.apiKey, "config/missing", "API key is required"); assert.type(value, "string", "validation/type", "Expected string value"); assert.fail("not-implemented", "This feature is not yet implemented"); ``` ### Pattern Matching ```typescript import { match } from "@fajarnugraha37/error"; const result = match(error, { "db/timeout": (err) => retry(err), "db/connection": (err) => reconnect(err), "auth/expired": (err) => refreshToken(err), _: (err) => logAndThrow(err), // fallback }); ``` ### Formatting ```typescript import { format, formatOneLine } from "@fajarnugraha37/error"; // Short format console.log(formatOneLine(err)); // [payment/declined] Card was declined // Verbose format console.log(format(err, { stack: true })); // AppError: payment/declined // Message: Card was declined // ID: 01234567-89ab-cdef-0123-456789abcdef // Severity: error // ... ``` --- ## Problem Details (RFC 9457) ```typescript import { toProblem, fromProblem } from "@fajarnugraha37/error"; const err = error("not-found", "Resource not found", { status: 404 }); // Convert to Problem Details const problem = toProblem(err); // { // type: "urn:error:not-found", // title: "not-found", // status: 404, // detail: "Resource not found", // instance: "01234567-...", // severity: "error" // } // Convert from Problem Details const restored = fromProblem(problem); ``` ### HTTP Response Mapping | Severity | Default Status | Use Case | | -------- | -------------- | ---------------------------- | | `info` | 200 | Informational responses | | `warn` | 400 | Client warnings | | `error` | 500 | Server errors | | `fatal` | 500 | Critical failures | Custom status codes override defaults via the `status` option. --- ## Validation ```typescript import { makeValidationError, fromZodError, type ValidationIssue, } from "@fajarnugraha37/error/validation"; // Manual validation errors const issues: ValidationIssue[] = [ { path: ["user", "email"], code: "invalid_email", message: "Invalid email format", }, { path: ["user", "age"], code: "too_small", message: "Age must be at least 18", }, ]; const err = makeValidationError(issues, { status: 422 }); // Zod integration import { z } from "zod"; const schema = z.object({ email: z.string().email(), age: z.number().min(18), }); try { schema.parse(data); } catch (zodError) { throw fromZodError(zodError); } ``` ### Validation Adapters - **Zod**: `fromZodError(zodError)` - **TypeBox**: `fromTypeboxError(errors)` - **AJV**: `fromAjvError(errors)` All converters produce `AppError` with `code: "validation/failed"` and normalized issues. --- ## Framework Adapters ### Express ```typescript import { expressErrorHandler, asyncHandler } from "@fajarnugraha37/error/adapters/express"; app.get("/users/:id", asyncHandler(async (req, res) => { const user = await getUser(req.params.id); res.json(user); })); app.use(expressErrorHandler()); ``` ### Fastify ```typescript import { fastifyErrorHandler } from "@fajarnugraha37/error/adapters/fastify"; const fastify = Fastify(); fastifyErrorHandler(fastify); ``` ### Koa ```typescript import { koaErrorMiddleware } from "@fajarnugraha37/error/adapters/koa"; app.use(koaErrorMiddleware()); ``` ### Hono ```typescript import { honoErrorHandler } from "@fajarnugraha37/error/adapters/hono"; app.onError(honoErrorHandler()); ``` ### H3 (Nuxt/Nitro) ```typescript import { h3ErrorHandler } from "@fajarnugraha37/error/adapters/h3"; export default defineEventHandler((event) => { return h3ErrorHandler()(event); }); ``` ### Elysia ```typescript import { elysiaErrorHandler } from "@fajarnugraha37/error/adapters/elysia"; new Elysia() .onError(elysiaErrorHandler()) .listen(3000); ``` ### GraphQL ```typescript import { toGraphQLError } from "@fajarnugraha37/error/adapters/graphql"; const resolvers = { Query: { user: (_, { id }) => { try { return getUser(id); } catch (e) { throw toGraphQLError(fromUnknown(e)); } }, }, }; ``` --- ## Observability ### OpenTelemetry ```typescript import { trace } from "@opentelemetry/api"; import { recordException } from "@fajarnugraha37/error/observability/otel"; const span = trace.getActiveSpan(); try { await operation(); } catch (e) { const err = fromUnknown(e); recordException(span, err, { "service.name": "api" }); throw err; } ``` ### Sentry ```typescript import * as Sentry from "@sentry/node"; import { toSentryEvent, getSentryFingerprint } from "@fajarnugraha37/error/observability/sentry"; try { await operation(); } catch (e) { const err = fromUnknown(e); const event = toSentryEvent(err); event.fingerprint = getSentryFingerprint(err); Sentry.captureEvent(event); } ``` --- ## Security & Redaction ```typescript import { redact, safeStringify } from "@fajarnugraha37/error"; const data = { username: "alice", password: "secret123", apiToken: "xyz", }; const safe = redact(data); // { username: "alice", password: "[REDACTED]", apiToken: "[REDACTED]" } const json = safeStringify(data); // Handles circular refs, truncates large objects, redacts sensitive keys ``` **Default sensitive patterns:** - `/pass(word)?/i` - `/token/i` - `/secret/i` - `/authorization/i` - `/api[_-]?key/i` - `/private[_-]?key/i` Custom redaction: ```typescript const redacted = redact(data, (keyPath) => { const key = keyPath[keyPath.length - 1]; return key === "ssn" || key === "creditCard"; }); ``` --- ## Exports Map The library uses conditional exports for optimal tree-shaking: ```javascript // Core import { error } from "@fajarnugraha37/error"; import { ok, err } from "@fajarnugraha37/error/result"; import { assert } from "@fajarnugraha37/error/assert"; import { format } from "@fajarnugraha37/error/format"; import { toProblem } from "@fajarnugraha37/error/problem"; import { makeValidationError } from "@fajarnugraha37/error/validation"; // Adapters import { toResponse } from "@fajarnugraha37/error/adapters/web"; import { expressErrorHandler } from "@fajarnugraha37/error/adapters/express"; import { fastifyErrorHandler } from "@fajarnugraha37/error/adapters/fastify"; import { koaErrorMiddleware } from "@fajarnugraha37/error/adapters/koa"; import { honoErrorHandler } from "@fajarnugraha37/error/adapters/hono"; import { h3ErrorHandler } from "@fajarnugraha37/error/adapters/h3"; import { elysiaErrorHandler } from "@fajarnugraha37/error/adapters/elysia"; import { toGraphQLError } from "@fajarnugraha37/error/adapters/graphql"; // Observability import { recordException } from "@fajarnugraha37/error/observability/otel"; import { toSentryEvent } from "@fajarnugraha37/error/observability/sentry"; ``` ### ESM / CJS / Browser The library supports all module systems: ```typescript // ESM (preferred) import { error } from "@fajarnugraha37/error"; // CommonJS const { error } = require("@fajarnugraha37/error"); // Browser (via bundler - Vite, Webpack, etc.) import { error } from "@fajarnugraha37/error"; ``` --- ## FAQ ### Why a single package instead of multiple packages? - **Simpler dependency management** – One version to track - **Better tree-shaking** – Bundlers can eliminate unused code - **Consistent types** – No version mismatches between packages - **Easier maintenance** – Single source of truth ### How do I use this with TypeScript strict mode? The library is built with `strict: true` and all public APIs are fully typed with no `any`. Enable strict mode in your `tsconfig.json`: ```json { "compilerOptions": { "strict": true } } ``` ### Do I need to install peer dependencies? No! Adapters use type-only imports and runtime checks. Install only what you use: ```bash # Only if using Express adapter bun add express # Only if using Zod validation bun add zod ``` ### How does stack normalization work? The library detects the runtime (Bun/Node/Browser) and: - Normalizes line endings - Filters internal frames (node_modules, library internals) - Limits stack frames (default: 50) - Applies source maps (Bun, browser) ### Can I extend AppError? Yes, but you'll lose the freezing behavior. Better approach: ```typescript function createPaymentError(code: string, message: string, data?: unknown) { return error(`payment/${code}`, message, { status: 402, tags: ["payment"], data, }); } ``` ### How do I debug errors in production? Use the error `id` (UUID v7) for correlation: ```typescript const err = error("api/failed", "Request failed"); console.error(`Error ${err.id} occurred at ${new Date(err.timestamp)}`); // In logs/monitoring, search by ID logger.error({ errorId: err.id, ...err.toJSON() }); ``` --- ## Performance Notes - **Lazy stack capture**: Set `captureStack: false` for high-volume errors - **Redaction bounds**: Max depth 10, max size 1000 items - **Stack truncation**: Limits to 50 frames by default - **Frozen instances**: Prevents accidental mutations --- ## License MIT © Fajar Nugraha --- ## Contributing Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. --- ## Links - [GitHub Repository](https://github.com/fajarnugraha37/nope) - [npm Package](https://www.npmjs.com/package/@fajarnugraha37/error) - [Issues](https://github.com/fajarnugraha37/nope/issues) --- **Made with ❤️ for developers who care about error handling.**