npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@voyagerpoland/results

v0.1.0

Published

Result Pattern library for TypeScript — Voyager.Common.Results equivalent

Downloads

119

Readme

@voyagerpoland/results

Result Pattern library for TypeScript — equivalent of Voyager.Common.Results from the .NET ecosystem.

Built on neverthrow with a thin wrapper providing:

  • Result<T> / ResultAsync<T> type aliases with baked-in AppError
  • AppError class with 13 factory methods, error classification, HTTP mapping, error chaining, and error context enrichment
  • Method aliases matching C# conventions: .bind(), .tap(), .tapError(), .mapError()
  • RetryretryAsync() + bindWithRetry() with exponential backoff, mirroring C# RetryPolicy
  • Circuit BreakerCircuitBreakerPolicy + executeWithBreaker(), mirroring C# Voyager.Common.Resilience
  • ESLint enforcement via neverthrow/must-use-result

Installation

npm install @voyagerpoland/results neverthrow

neverthrow is a peer dependency — you must install it alongside this package.

Quick Start

1. Creating and consuming a Result

import { Result, success, failure, AppError } from '@voyagerpoland/results';

function getUser(id: number): Result<User> {
  if (id <= 0) return failure(AppError.validation('User ID must be positive'));

  const user = repository.find(id);
  if (!user) return failure(AppError.notFound(`User ${id} not found`));

  return success(user);
}

// Both branches are required — the compiler enforces exhaustive handling
getUser(42).match(
  (user) => console.log(user.name),
  (error) => console.log(error.message),
);

2. Chaining with bind / map / tap

getUser(42)
  .bind((user) => getOrders(user.id)) // flatMap — returns Result<Order[]>
  .map((orders) => orders.length) // transform value — returns Result<number>
  .tap((count) => console.log(`Found ${count} orders`)) // side-effect, value unchanged
  .match(
    (count) => showOrders(count),
    (error) => showError(error),
  );

3. Error handling with AppError classification

import { tryAsync, AppError } from '@voyagerpoland/results';

const result = await tryAsync(() => fetch('/api/data').then((r) => r.json()));

result.match(
  (data) => render(data),
  (error) => {
    if (error.isTransient) {
      // Unavailable, Timeout, TooManyRequests — safe to retry
      scheduleRetry();
    } else if (error.isBusinessError) {
      // Validation, NotFound, Business, Conflict, Unauthorized, Permission
      showUserMessage(error.message);
    } else {
      // Infrastructure errors (Database, Unexpected)
      logToSentry(error.toDetailedString());
    }
  },
);

4. Error chaining with withInner / getRootCause

import { failure, AppError, ErrorType } from '@voyagerpoland/results';

// Wrap a low-level error with business context
const dbError = AppError.database('Connection refused');
const serviceError = AppError.unavailable('Order service down').withInner(dbError);

// Walk the chain to find the root cause
serviceError.getRootCause(); // → dbError (Database: 'Connection refused')

// Search the chain for a specific type
serviceError.hasInChain((e) => e.type === ErrorType.Database); // → true

// toDetailedString() formats the full chain for logging
console.log(serviceError.toDetailedString());
// [Unavailable] Service.Unavailable: Order service down
//   [Database] Database.Error: Connection refused

5. Error enrichment with wrapError / addErrorContext

import { AppError, ErrorType } from '@voyagerpoland/results';

// wrapError — wrap a low-level error with higher-level context
const dbError = AppError.database('Connection refused');
const wrapped = dbError.wrapError(ErrorType.Unexpected, 'Order query failed');
// wrapped.type === ErrorType.Unexpected
// wrapped.innerError === dbError
// wrapped.getRootCause() === dbError

// addErrorContext — attach structured metadata for diagnostics
const error = AppError.notFound('Order not found')
  .addErrorContext('orderId', '12345')
  .addErrorContext('userId', 'u-99');

error.context.get('orderId'); // '12345'
error.context.get('userId'); // 'u-99'

// Context is included in toDetailedString() output:
console.log(error.toDetailedString());
// [NotFound] NotFound: Order not found
//   orderId: 12345
//   userId: u-99

6. Recovery with orElse

import { failure, success, AppError, ErrorType } from '@voyagerpoland/results';

// orElse lets you recover from specific errors
getUser(userId).orElse((error) => {
  if (error.type === ErrorType.NotFound) {
    return success(defaultUser); // recover to a default
  }
  return failure(error); // propagate other errors
});

7. Transforming errors with mapError

import { failure, AppError } from '@voyagerpoland/results';

// mapError transforms the error without touching the success path
getOrder(orderId).mapError((error) =>
  AppError.business('Order.Processing', `Failed to process order: ${error.message}`),
);

8. Custom error codes (two-argument factory)

// All factory methods accept (message) or (code, message)
AppError.validation('Email is required');
// → { type: Validation, code: 'Validation.Failed', message: 'Email is required' }

AppError.validation('User.Email', 'Email is required');
// → { type: Validation, code: 'User.Email', message: 'Email is required' }

AppError.notFound('Order.Missing', `Order ${id} not found`);
// → { type: NotFound, code: 'Order.Missing', message: 'Order 42 not found' }

Async Operations

Wrapping async calls with tryAsync

import { ResultAsync, tryAsync, AppError } from '@voyagerpoland/results';

// Any async function that might throw — tryAsync catches it as AppError
function fetchUser(id: number): ResultAsync<User> {
  return tryAsync(() => fetch(`/api/users/${id}`).then((r) => r.json()));
}

Async chaining — same operators as sync

// The entire chain is async — no await needed until the end
const output = await fetchUser(42)
  .bind((user) => fetchOrders(user.id)) // ResultAsync<Order[]>
  .map((orders) => orders.filter((o) => o.status === 'active')) // transform
  .tap((orders) => console.log(`Found ${orders.length} active orders`)) // side-effect
  .tapError((error) => logger.error(error.toDetailedString())) // log errors
  .match(
    (orders) => ({ ok: true, data: orders }),
    (error) => ({ ok: false, message: error.message }),
  );

Creating async Results directly

import { successAsync, failureAsync } from '@voyagerpoland/results';

// When you already have the value (no async work needed)
function validateAndFetch(id: number): ResultAsync<User> {
  if (id <= 0) {
    return failureAsync(AppError.validation('ID must be positive'));
  }
  return fetchUser(id);
}

Mixing sync validation with async operations

import { success, failure, ResultAsync } from '@voyagerpoland/results';

function processOrder(order: Order): ResultAsync<Receipt> {
  // Start with sync validation, chain into async
  const validated =
    order.total > 0
      ? success(order)
      : failure<Order>(AppError.validation('Order total must be positive'));

  // Tip: when the success type is anonymous (no named interface), use `typeof`
  // to avoid repeating the type literal:
  //   .bind((user) =>
  //     user.age >= 18
  //       ? success(user)
  //       : failure<typeof user>(AppError.validation('Too young'))
  //   )

  // Sync Result auto-lifts to ResultAsync when you bind an async function
  return new ResultAsync(Promise.resolve(validated))
    .bind((o) => chargePayment(o)) // ResultAsync<Payment>
    .bind((p) => generateReceipt(p)) // ResultAsync<Receipt>
    .tapError((error) => {
      if (error.shouldRetry) {
        retryQueue.enqueue(order.id);
      }
    });
}

Validating with .ensure()

import { tryAsync, AppError } from '@voyagerpoland/results';

// .ensure() validates the value inside a Result or ResultAsync
// If the predicate fails, the pipeline short-circuits to Err
const result = await tryAsync(() => fetch('/api/users/42').then((r) => r.json())).ensure(
  (user) => user.age >= 18,
  (user) => AppError.validation(`User ${user.name} is underage`),
);

// Chain multiple validations fluently
const output = await fetchOrder(orderId)
  .ensure(
    (order) => order.items.length > 0,
    () => AppError.validation('Order has no items'),
  )
  .ensure(
    (order) => order.total > 0,
    () => AppError.validation('Order total must be positive'),
  )
  .bind((order) => submitPayment(order))
  .match(
    (receipt) => ({ ok: true, data: receipt }),
    (error) => ({ ok: false, message: error.message }),
  );

Parallel async operations with combine

import { ResultAsync } from 'neverthrow';

// Run multiple async operations in parallel
const combined = await ResultAsync.combine([
  fetchUser(userId),
  fetchOrders(userId),
  fetchPreferences(userId),
]);

combined.match(
  ([user, orders, prefs]) => renderDashboard(user, orders, prefs),
  (error) => showError(error.message), // first error wins
);

Retry

Built-in retry mechanism integrated with AppError classification — mirrors RetryPolicy / RetryPolicies from Voyager.Common.Results (C#).

retryAsync — standalone retry

import { retryAsync, RetryPolicies, AppError } from '@voyagerpoland/results';

// Retry transient errors (Unavailable, Timeout) with exponential backoff
const result = await retryAsync(
  () => fetchOrder(orderId),
  RetryPolicies.transientErrors(3, 1000), // max 3 attempts, 1s base delay
  (attempt, error, delayMs) => console.warn(`Retry ${attempt}: ${error.code}, delay ${delayMs}ms`),
);

bindWithRetry — retry in a pipeline

import { bindWithRetry, RetryPolicies } from '@voyagerpoland/results';

// If getConnection() fails — short-circuits without retry.
// If executeQuery() fails with transient error — retries up to 3 times.
const result = await bindWithRetry(
  getConnection(),
  (conn) => executeQuery(conn),
  RetryPolicies.transientErrors(3, 1000),
);

Custom retry policy

import { RetryPolicies } from '@voyagerpoland/results';

// Linear backoff, retry on specific error code
const policy = RetryPolicies.custom(
  5,
  (e) => e.code === 'RATE_LIMIT' || e.isTransient,
  (attempt) => 500 * attempt,
);

// Exponential backoff with jitter
const withJitter = RetryPolicies.custom(
  3,
  (e) => e.isTransient,
  (attempt) => {
    const base = 1000 * Math.pow(2, attempt - 1);
    return base * (0.5 + Math.random() * 0.5); // 50-100% of base delay
  },
);

Pre-built policies

| Policy | Behavior | | --------------------------------------------- | ------------------------------------------------------------------------ | | RetryPolicies.transientErrors(max, baseMs) | Exponential backoff (base * 2^(attempt-1)), Unavailable + Timeout only | | RetryPolicies.custom(max, predicate, delay) | Custom predicate and delay strategy | | RetryPolicies.default() | Same as transientErrors(3, 1000) |

Note: transientErrors() retries only Unavailable and Timeout — not TooManyRequests (429). Use custom() to include 429 or other errors.

Circuit Breaker

Built-in circuit breaker protecting against cascading failures — mirrors CircuitBreakerPolicy from Voyager.Common.Resilience (C#).

Basic usage

import {
  CircuitBreakerPolicy,
  executeWithBreaker,
  retryAsync,
  RetryPolicies,
} from '@voyagerpoland/results';

// Create a breaker — shared across all calls to the same resource
const breaker = new CircuitBreakerPolicy({
  failureThreshold: 5, // open after 5 infrastructure failures
  openTimeoutMs: 30_000, // try half-open after 30s
  halfOpenMaxAttempts: 1, // allow 1 test request in half-open
  onStateChanged: (oldState, newState, failures, lastError) =>
    console.warn(`Circuit: ${oldState} → ${newState}, failures: ${failures}`),
});

// Execute through the breaker
const result = await executeWithBreaker(() => fetchOrder(id), breaker);

Retry + Circuit Breaker (defense in depth)

// Retry is INSIDE the breaker — breaker sees the final result after all retries
const result = await executeWithBreaker(
  () => retryAsync(() => fetchOrder(id), RetryPolicies.transientErrors(3, 1000)),
  breaker,
);

// Flow:
// 1. fetchOrder() fails → retry 3x with backoff
// 2. All retries exhausted → breaker.recordFailure() → failureCount++
// 3. After 5 such sequences → circuit OPENS
// 4. Next call → instant Err(CircuitBreakerOpen), zero HTTP calls

bindWithBreaker — in a pipeline

import { bindWithBreaker } from '@voyagerpoland/results';

// If getUser() fails — short-circuits without checking breaker.
// If callExternalService() fails — breaker records the failure.
const result = await bindWithBreaker(getUser(userId), (user) => callExternalService(user), breaker);

Circuit states

| State | Behavior | | ------------ | ------------------------------------------------------------------- | | Closed | Normal — requests pass through, failures are counted | | Open | Broken — requests fail immediately with CircuitBreakerOpen | | HalfOpen | Testing — allows limited requests; success closes, failure re-opens |

What errors count?

Only infrastructure errors increment the failure counter (shouldCountForCircuitBreaker): Unavailable, Timeout, Database, Unexpected.

Business errors (Validation, NotFound, etc.) are ignored by the breaker.

UI integration with shouldDisableFeature

// When circuit is open, the error has shouldDisableFeature = true
result.match(
  (data) => render(data),
  (error) => {
    if (error.shouldDisableFeature) {
      showFeatureUnavailable(); // circuit breaker is open
    } else {
      showError(error.message);
    }
  },
);

C# to TypeScript Mapping

| Voyager.Common.Results (C#) | @voyagerpoland/results (TypeScript) | Notes | | --------------------------------- | ------------------------------------- | ----------------------------------- | | Result.Success() | success() | void result | | Result<T>.Success(value) | success(value) | with value | | Result.Failure(error) | failure(error) | void result | | Result<T>.Failure(error) | failure<T>(error) | with type param | | Result.Try(action) | trySync(fn, errorMapper?) | wraps sync exceptions | | Result.TryAsync(func) | tryAsync(fn, errorMapper?) | wraps async exceptions | | Error.ValidationError(msg) | AppError.validation(msg) | factory method | | Error.NotFoundError(msg) | AppError.notFound(msg) | factory method | | Error.None | AppError.None | sentinel | | .Bind(fn) | .bind(fn) | alias for .andThen() | | .Map(fn) | .map(fn) | neverthrow native | | .Tap(fn) | .tap(fn) | alias for .andTee() | | .TapError(fn) | .tapError(fn) | alias for .orTee() | | .MapError(fn) | .mapError(fn) | alias for .mapErr() | | .OrElse(fn) | .orElse(fn) | neverthrow native | | .Match(onOk, onErr) | .match(onOk, onErr) | neverthrow native | | .Ensure(pred, error) | .ensure(pred, errorFn) | chainable method | | .Finally(action) | .finally(fn) | side-effect on both paths | | RetryPolicy (delegate) | RetryPolicy (type alias) | (attempt, error) → Result<number> | | RetryPolicies.TransientErrors() | RetryPolicies.transientErrors() | camelCase | | RetryPolicies.Custom() | RetryPolicies.custom() | camelCase | | .BindWithRetryAsync(fn, p) | bindWithRetry(result, fn, p) | standalone function | | CircuitBreakerPolicy | CircuitBreakerPolicy | sync API (JS single-threaded) | | CircuitState | CircuitState | identical enum values | | .BindWithCircuitBreakerAsync() | bindWithBreaker(result, fn, b) | standalone function | | policy.ExecuteAsync(fn) | executeWithBreaker(fn, b) | standalone function | | OnStateChanged (property) | onStateChanged (constructor option) | immutable after construction | | Error.WrapError(type, msg) | error.wrapError(type, msg) | creates outer wrapping inner | | Error.AddErrorContext(key, val) | error.addErrorContext(key, val) | immutable — returns new instance | | Result.GetErrors(list) | getErrors(results) | extracts AppError[] from Result[] | | Result.GetSuccessValues(list) | getSuccessValues(results) | extracts T[] from Result[] | | Result.Partition(list) | partition(results) | splits into successes + errors | | Result.AllSuccess(list) | allSuccess(results) | true if all Ok | | Result.AnySuccess(list) | anySuccess(results) | true if at least one Ok | | return user; (implicit) | return success(user); | no implicit conversions in TS |

API Reference

Types

| Export | Description | | ----------------------- | -------------------------------------------------------------------- | | Result<T = void> | Synchronous result with AppError baked in. Default T is void. | | ResultAsync<T = void> | Asynchronous result with AppError baked in. Default T is void. |

Factory Functions

| Function | Description | | ------------------------------------ | ------------------------------------------------------- | | success() / success(value) | Creates a successful Result | | failure(error) | Creates a failed Result | | successAsync(value) | Creates a successful ResultAsync | | failureAsync(error) | Creates a failed ResultAsync | | trySync(fn, errorMapper?) | Executes sync function, catches exceptions as AppError | | tryAsync(fn, errorMapper?) | Executes async function, catches rejections as AppError | | ensure(result, predicate, errorFn) | Validates a Result against a predicate (standalone) |

Collection Utilities

| Function | Description | | --------------------------- | -------------------------------------------------------------- | | getErrors(results) | Extracts AppError[] from Err results in an array | | getSuccessValues(results) | Extracts T[] from Ok results in an array | | partition(results) | Splits into { successes: T[], errors: AppError[] } | | allSuccess(results) | Returns true if every result is Ok (vacuous truth for empty) | | anySuccess(results) | Returns true if at least one result is Ok |

AppError

Immutable error class with private constructor — all instances created via factory methods.

Instance properties (readonly):

| Property | Type | Description | | --------------- | ----------------------------- | ----------------------------------------------------- | | type | ErrorType | Semantic error category | | code | string | Machine-readable code (e.g. 'Validation.Failed') | | message | string | Human-readable description | | innerError | AppError \| undefined | Optional wrapped cause (error chaining) | | stackTrace | string \| undefined | Stack trace from caught exception, if any | | exceptionType | string \| undefined | Original exception class name (e.g. 'TypeError') | | source | string \| undefined | Originating module or service name | | context | ReadonlyMap<string, string> | Key-value metadata for diagnostics (empty by default) |

Factory methods (all accept (message) or (code, message)):

validation, notFound, unauthorized, permission, database, business, conflict, unavailable, timeout, cancelled, unexpected, circuitBreakerOpen, tooManyRequests

Classification getters:

| Getter | True for | | ------------------------------ | ------------------------------------------------------------------ | | isTransient | Unavailable, Timeout, TooManyRequests | | shouldRetry | Same as isTransient | | isBusinessError | Validation, NotFound, Business, Conflict, Unauthorized, Permission | | isInfrastructureError | Database, Unexpected | | shouldDisableFeature | CircuitBreakerOpen | | shouldCountForCircuitBreaker | Unavailable, Timeout, Database, Unexpected |

Error chaining and enrichment:

  • withInner(error) — wraps a cause
  • wrapError(outerType, message) — creates a new outer error with this as inner cause
  • getRootCause() — walks the chain to the deepest error
  • hasInChain(predicate) — tests any error in the chain
  • addErrorContext(key, value) — returns a copy with additional metadata entry

HTTP mapping:

  • httpStatusCode — maps ErrorType to HTTP status
  • AppError.fromHttpError({ status, message? }) — maps HTTP response to AppError
  • AppError.fromException(exception, errorType?) — maps caught exceptions to AppError (optional type override)

Diagnostics:

  • toDetailedString() — formats the full error chain as indented multi-line string for logging

ErrorType Enum

| Member | HTTP | Category | | -------------------- | ---- | --------------- | | None | 200 | Sentinel | | Validation | 400 | Business | | Unauthorized | 401 | Business | | Permission | 403 | Business | | NotFound | 404 | Business | | Conflict | 409 | Business | | Business | 422 | Business | | TooManyRequests | 429 | Transient | | Unavailable | 503 | Transient | | Timeout | 504 | Transient | | CircuitBreakerOpen | 503 | Circuit breaker | | Cancelled | 499 | — | | Database | 500 | Infrastructure | | Unexpected | 500 | Infrastructure |

Method Aliases (prototype patching)

| Alias | neverthrow original | Description | | ------------------ | ------------------- | -------------------------------- | | .bind(fn) | .andThen(fn) | Chain / flatMap | | .tap(fn) | .andTee(fn) | Side-effect on success | | .tapError(fn) | .orTee(fn) | Side-effect on error | | .mapError(fn) | .mapErr(fn) | Transform the error | | .ensure(pred,fn) | (new) | Validate value against predicate | | .finally(fn) | (new) | Side-effect regardless of Ok/Err |

Retry

| Export | Description | | ---------------------------------------------- | ---------------------------------------------------------------- | | RetryPolicy | Type: (attempt, error) => Result<number> — Ok(delay) or Err | | RetryPolicies.transientErrors(max?, baseMs?) | Exponential backoff for Unavailable + Timeout (default: 3, 1000) | | RetryPolicies.custom(max, predicate, delay) | Custom retry predicate and delay strategy | | RetryPolicies.default() | Same as transientErrors() with defaults | | retryAsync(fn, policy?, onRetry?) | Execute async operation with retry | | bindWithRetry(result, fn, policy?, onRetry?) | Bind ResultAsync to operation with retry |

Circuit Breaker

| Export | Description | | -------------------------------------- | --------------------------------------------------------------- | | CircuitState | Enum: Closed, Open, HalfOpen | | CircuitBreakerPolicy | Stateful class tracking failures and managing state transitions | | CircuitBreakerOptions | Constructor options (threshold, timeout, maxAttempts, callback) | | CircuitBreakerStateChangedCallback | Callback type for state change notifications | | executeWithBreaker(fn, breaker) | Execute async operation through circuit breaker | | bindWithBreaker(result, fn, breaker) | Bind ResultAsync to operation with circuit breaker |

Re-exports from neverthrow

ok, err, okAsync, errAsync, safeTry

Angular Integration Example

Service — HTTP calls with error mapping

import { ResultAsync } from 'neverthrow';
import {
  AppError,
  CircuitBreakerPolicy,
  executeWithBreaker,
  retryAsync,
  RetryPolicies,
} from '@voyagerpoland/results';
import type { ResultAsync as AppResultAsync } from '@voyagerpoland/results';

/**
 * Converts an Angular HttpClient Observable to a ResultAsync,
 * mapping HTTP error codes to the appropriate AppError type.
 *
 * 400 → Validation, 401 → Unauthorized, 403 → Permission,
 * 404 → NotFound, 409 → Conflict, 503 → Unavailable, etc.
 */
function fromHttp<T>(request: Observable<T>): AppResultAsync<T> {
  return ResultAsync.fromPromise(firstValueFrom(request), (err): AppError => {
    const httpErr = err as HttpErrorResponse;
    return AppError.fromHttpError({
      status: httpErr.status,
      message: httpErr.error?.message ?? httpErr.message,
    });
  });
}

@Injectable({ providedIn: 'root' })
export class OrderService {
  constructor(private http: HttpClient) {}

  // Circuit breaker shared across all calls to Order API
  private readonly breaker = new CircuitBreakerPolicy({
    failureThreshold: 5,
    openTimeoutMs: 30_000,
    onStateChanged: (oldState, newState, failures) =>
      console.warn(`Order circuit: ${oldState} → ${newState}, failures: ${failures}`),
  });

  getOrder(id: number): AppResultAsync<Order> {
    return executeWithBreaker(
      () =>
        retryAsync(
          () => fromHttp(this.http.get<Order>(`/api/orders/${id}`)),
          RetryPolicies.transientErrors(3, 1000),
        ),
      this.breaker,
    );
  }

  /** Full async pipeline: fetch → validate → process */
  processOrder(id: number): AppResultAsync<OrderConfirmation> {
    return this.getOrder(id)
      .ensure(
        (order) => order.items.length > 0,
        () => AppError.validation('Order has no items'),
      )
      .ensure(
        (order) => order.total > 0,
        () => AppError.validation('Order total must be positive'),
      )
      .bind((order) => this.submitPayment(order))
      .tap((confirmation) => console.log(`Order ${confirmation.id} confirmed`))
      .tapError((error) => console.error(error.toDetailedString()));
  }

  private submitPayment(order: Order): AppResultAsync<OrderConfirmation> {
    return fromHttp(
      this.http.post<OrderConfirmation>('/api/payments', {
        orderId: order.id,
        amount: order.total,
      }),
    );
  }
}

Component — consuming the async result

@Component({ ... })
export class OrderComponent {
  async onSubmit(orderId: number): Promise<void> {
    const result = await this.orderService.processOrder(orderId);

    result.match(
      (confirmation) => {
        this.toastr.success(`Order ${confirmation.id} confirmed`);
        this.router.navigate(['/orders', confirmation.id]);
      },
      (error) => {
        if (error.shouldDisableFeature) {
          // CircuitBreakerOpen — service is down, disable the feature
          this.toastr.warning('Service temporarily unavailable');
        } else if (error.isBusinessError) {
          // Validation, NotFound, Business, etc.
          this.form.setErrors({ server: error.message });
        } else if (error.isTransient) {
          // Unavailable, Timeout — retries already exhausted
          this.toastr.warning('Service temporarily unavailable, please try again later');
        } else {
          // Infrastructure errors (Database, Unexpected)
          this.toastr.error('An unexpected error occurred');
        }
      },
    );
  }
}

Release Process

Tag-based — same workflow as Voyager.Common.Results (.NET).

# 1. Describe changes (during development)
npx changeset

# 2. Bump version in package.json + generate CHANGELOG.md
npx changeset version

# 3. Commit the version bump
git add . && git commit -m "chore: release v0.1.0"

# 4. Tag and push — triggers CI → publish → GitHub Release
git tag v0.1.0
git push && git push --tags

The publish workflow (.github/workflows/publish.yml) will:

  1. Build & test on Node 18/20/22 (full matrix with lint, typecheck, coverage)
  2. Validate that package.json version matches the tag
  3. Publish to npm with provenance
  4. Create GitHub Release with auto-generated notes

Prerelease tags

| Tag | npm dist-tag | GitHub Release | |-----|-------------|----------------| | v0.1.0 | latest | Release | | v0.2.0-beta.1 | beta | Pre-release | | v0.2.0-alpha.1 | alpha | Pre-release | | v0.2.0-rc.1 | rc | Pre-release |

Install a prerelease: npm install @voyagerpoland/results@beta

Conventions

This library follows the same conventions as Voyager.Common.Results in the .NET ecosystem:

  • Result Pattern only — never throw exceptions for expected failures. Use Result<T> as return type.
  • Exhaustive handling — always consume results via .match() or chain operators. ESLint rule neverthrow/must-use-result enforces this.
  • Semantic error types — use the most specific ErrorType and AppError factory method for the situation.
  • Test namingdescribe('MethodName') / it('scenario → expected').
  • Versioning — SemVer + Conventional Commits + v prefix for git tags.

Architecture Decisions

License

MIT