@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-inAppErrorAppErrorclass with 13 factory methods, error classification, HTTP mapping, error chaining, and error context enrichment- Method aliases matching C# conventions:
.bind(),.tap(),.tapError(),.mapError() - Retry —
retryAsync()+bindWithRetry()with exponential backoff, mirroring C#RetryPolicy - Circuit Breaker —
CircuitBreakerPolicy+executeWithBreaker(), mirroring C#Voyager.Common.Resilience - ESLint enforcement via
neverthrow/must-use-result
Installation
npm install @voyagerpoland/results neverthrow
neverthrowis 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 refused5. 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-996. 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). Usecustom()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 callsbindWithBreaker — 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 causewrapError(outerType, message)— creates a new outer error withthisas inner causegetRootCause()— walks the chain to the deepest errorhasInChain(predicate)— tests any error in the chainaddErrorContext(key, value)— returns a copy with additional metadata entry
HTTP mapping:
httpStatusCode— maps ErrorType to HTTP statusAppError.fromHttpError({ status, message? })— maps HTTP response to AppErrorAppError.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 --tagsThe publish workflow (.github/workflows/publish.yml) will:
- Build & test on Node 18/20/22 (full matrix with lint, typecheck, coverage)
- Validate that
package.jsonversion matches the tag - Publish to npm with provenance
- 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 ruleneverthrow/must-use-resultenforces this. - Semantic error types — use the most specific
ErrorTypeandAppErrorfactory method for the situation. - Test naming —
describe('MethodName')/it('scenario → expected'). - Versioning — SemVer + Conventional Commits +
vprefix for git tags.
Architecture Decisions
- ADR-001: Result Pattern library selection
- ADR-002: Wrapper API over neverthrow
- ADR-003: ESLint Result enforcement
- ADR-004: npm package configuration
- ADR-005: Retry Policy
- ADR-006: RxJS vs Result — granice stosowania
- ADR-007: Result + Signals — aktualizacja UI
- ADR-008: NgRx + Result — granice odpowiedzialności
- ADR-009: Circuit Breaker Policy
- ADR-010: Typed HTTP Client z kontraktu
- ADR-011: @voyager/proxy — osobna biblioteka HTTP
- ADR-012: GitHub Actions — CI/CD i publikacja
- ROADMAP
