Skip to content

meeghele/neverthrow-metadata

Repository files navigation

CI TypeScript MIT License

NeverThrow + Metadata

⚠️ Warning: This project must be considered educational and is not meant for production use; see Rationale below.

An opinionated TypeScript wrapper/extension for neverthrow that enriches Result and ResultAsync with structured metadata, error codes, and observability features while preserving the original ergonomics.

Rationale

Although neverthrow can embed complex metadata directly in its Error type, this project implements a wrapper pattern with compatible exports, serving as a drop-in companion during refactors to add observability metadata without altering existing error types—ideal for neverthrow codebases needing extra debugging insight.

Features

  • Rich Metadata: Attach timestamps, error codes, severity levels, and custom metadata to every result
  • Error Translation: Convert thrown exceptions into structured, typed errors with wrapSync/wrapAsync
  • Standard Error Codes: Built-in error codes for common scenarios (network, validation, parsing, etc.)
  • Type Safety: Full TypeScript support with generic types
  • Observability Ready: Structured data perfect for logging, monitoring, and debugging
  • Zero Dependencies: Only requires neverthrow as a peer dependency

Quick Start

import { mok, merr, mwrapSync } from 'neverthrow-metadata';

// Success with metadata
const user = mok({ id: 'usr_2Nq8x9P4mL7vK3Fn', name: 'Sarah Chen' }, {
  source: 'database'
});

// Error with structured metadata
const error = merr(new Error('User not found'), 'USER_NOT_FOUND', {
  severity: 'medium',
  retryable: false
});

// Wrap risky operations
const parsed = mwrapSync(() => JSON.parse(data), 'PARSE_ERROR');

Basic Usage

import {
  MetadataWrapper,
  MetadataUtils,
  StandardErrorCodes,
  Severity,
  mok,
  merr,
  mwrap,
  mwrapSync
} from 'neverthrow-metadata';

const success = mok('cached-user-profile', { source: 'cache' });
const failure = merr(new Error('not found'), StandardErrorCodes.RESOURCE_NOT_FOUND, {
  severity: Severity.Medium,
  userMessage: 'User could not be located'
});

if (success.isOk()) {
  console.log(success.value.value); // => 'cached-user-profile'
}

if (failure.isErr()) {
  console.log(failure.error.metadata.code); // => 'RESOURCE_NOT_FOUND'
}

const parsed = mwrapSync(() => JSON.parse('{"ok":true}'), StandardErrorCodes.PARSE_ERROR);
const fetched = await mwrap(async () => fetchUser(), StandardErrorCodes.NETWORK_ERROR);

Basic Usage with Request Tracing

For production applications, you can enrich metadata with request tracing context:

import { mok, merr, mwrap } from 'neverthrow-metadata';

const success = mok('cached-user-profile', {
  source: 'redis-cluster',
  duration: 12,
  details: {
    'x-request-id': 'req-123',
    'x-trace-id': 'trace-456',
    orgId: 'example-enterprise',
    userId: 'user-789',
    cacheKey: 'user:profile:user-789'
  }
});

const failure = merr(new Error('Database connection timeout'), StandardErrorCodes.RESOURCE_NOT_FOUND, {
  severity: Severity.Medium,
  userMessage: 'Unable to load user profile at this time',
  details: {
    'x-request-id': 'req-123',
    'x-trace-id': 'trace-456',
    orgId: 'example-enterprise',
    userId: 'user-789',
    dbHost: 'pg-prod-read-02.example-db.com'
  }
});

if (success.isOk()) {
  console.log(success.value.value); // => 'cached-user-profile'
  console.log(success.value.metadata.details?.orgId); // => 'example-enterprise'
}

if (failure.isErr()) {
  console.log(failure.error.metadata.code); // => 'RESOURCE_NOT_FOUND'
  console.log(failure.error.metadata.details?.['x-request-id']); // => 'req-123'
}

const fetched = await mwrap(async () => fetchUserProfile('user-789'), StandardErrorCodes.NETWORK_ERROR, {
  details: {
    'x-request-id': 'req-123',
    'x-trace-id': 'trace-456',
    operationName: 'user.fetchProfile',
    userId: 'user-789'
  }
});

Utilities

const retryable = MetadataWrapper.failure(new Error('timeout'), StandardErrorCodes.NETWORK_ERROR, {
  retryable: true,
  severity: Severity.High
});

console.log(MetadataUtils.isRetryable(retryable)); // true
console.log(MetadataUtils.getSeverity(retryable)); // 'high'

ControlFlow.retry and retryAsync can be used when you need simple retry loops based on the metadata returned from your operations.

Control Flow Error Codes

When control-flow helpers translate failures into metadata results they emit dedicated codes exposed via InternalMetadataErrorCodes. Use these to distinguish between synchronous throws (CONTROL_FLOW_SYNC_THROW), asynchronous rejections (CONTROL_FLOW_ASYNC_REJECTION), and operations that timed out (CONTROL_FLOW_TIMEOUT_EXCEEDED).

gRPC error status support is also baked in via GrpcErrorCodes and the matching GrpcStatusMetadata.

const status = GrpcStatusMetadata[GrpcErrorCodes.UNAVAILABLE];
const unavailable = MetadataWrapper.failure(
  new Error('service down'),
  'GRPC_UNAVAILABLE',
  {
    severity: status.defaultSeverity,
    retryable: status.defaultRetryable,
    details: { category: status.category }
  }
);

You can also define your own error codes:

const CUSTOM_CODE = 'MY_DOMAIN_ERROR';

const enriched = MetadataWrapper.failure(new Error('domain failure'), CUSTOM_CODE, {
  severity: Severity.High,
  retryable: true,
  details: { feature: 'billing' }
});

Type Safety

All helpers are fully typed:

import type { MetadataResult } from 'neverthrow-metadata';

interface User {
  id: string;
  email: string;
}

function loadUser(id: string): MetadataResult<User, Error> {
  return mwrapSync(() => {
    const user = maybeFindUser(id);
    if (!user) {
      throw new Error('User missing');
    }
    return user;
  }, StandardErrorCodes.RESOURCE_NOT_FOUND);
}

Request Tracing & Observability

The library supports rich request tracing metadata through the details field, making it perfect for distributed systems observability:

import { mok, merr, mwrap } from 'neverthrow-metadata';

// HTTP request with full trace context
const ctx = {
  'x-request-id': 'req-123',
  'x-trace-id': 'trace-456',
  'x-span-id': 'span-auth',
  'x-correlation-id': 'login-flow-789',
  orgId: 'example-saas-platform',
  userId: 'user-abc',
  sessionId: 'session-xyz',
  operationName: 'user.authenticate'
};

// Database operation with request correlation
const userQuery = await mwrap(async () => {
  return await db.users.findById('user-abc');
}, 'DATABASE_ERROR', {
  details: {
    ...ctx,
    query: 'SELECT * FROM users WHERE id = $1',
    dbInstance: 'pg-prod-read-02.example-db.com'
  }
});

// Microservice call with trace propagation
const paymentResult = await mwrap(async () => {
  return await paymentService.createPayment({
    amount: 2999,
    currency: 'usd'
  });
}, 'PAYMENT_ERROR', {
  details: {
    ...ctx,
    'x-span-id': 'span-payment', // New child span
    'x-parent-span-id': 'span-auth',
    serviceUrl: 'https://api.example-payments.com/v1/payments',
    amount: 2999,
    customerId: 'customer-123'
  }
});

Error Context for Debugging

Rich error context makes debugging production issues much easier:

const validationError = merr(
  new Error('Personal email domains are not permitted for business accounts'),
  'VALIDATION_ERROR',
  {
    severity: Severity.Medium,
    retryable: false,
    userMessage: 'Please use your business email address',
    details: {
      'x-request-id': 'req-456',
      'x-trace-id': 'trace-789',
      orgId: 'example-enterprise',
      userId: 'user-def',
      inputEmail: 'john.doe@personal-email.com',
      emailDomain: 'personal-email.com',
      validationRule: 'enterprise_email_domains_only',
      clientIp: '203.0.113.42',
      userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    }
  }
);

Advanced Usage

Async Operations with Timeout

import { mwrap, StandardErrorCodes } from 'neverthrow-metadata';

const fetchUser = async (id: string, ctx: any) => {
  const result = await mwrap(
    () => fetch(`/api/users/${id}`).then(r => r.json()),
    StandardErrorCodes.NETWORK_ERROR,
    {
      retryable: true,
      details: {
        ...ctx,
        operationName: 'api.users.fetch',
        timeoutMs: 5000,
        endpoint: `/api/users/${id}`,
        method: 'GET'
      }
    }
  );

  return result;
};

Retry Logic

import { ControlFlow } from 'neverthrow-metadata';

const result = await ControlFlow.retryAsync(
  () => fetchUser('123'),
  3, // max attempts
  200 // base delay in milliseconds
);

Compatibility

MetadataWrapper.wrapResult lets you take an existing Result from neverthrow and convert it into a metadata-aware result, and MetadataUtils.unwrapResult converts back to plain neverthrow results when you need to interoperate with existing code. Aliases enhanceResult and stripResult remain for backward compatibility.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Examples

Run the provided examples to see the library in action:

# Run all examples
make examples

# Run individual examples
npm run example:basic      # Basic patterns and request tracing
npm run example:advanced   # Advanced risky operations with rich metadata

Contributing

Contributions are welcome, please follow the semantic versioning branch naming convention:

  • main: Production-ready code
  • develop: Integration branch for features
  • feat/: New features (feat/user-authentication)
  • fix/: Bug fixes (fix/connection-timeout)
  • chore/: Maintenance (chore/update-dependencies)

Author

Michele Tavella - meeghele@proton.me