Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@ jobs:
cache: npm
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: npm ci --ignore-scripts --no-audit --fund-no
run: npm ci --no-audit --no-fund
- name: Run tests
run: npm test
- name: Build Package
run: npm run build --if-present
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}

2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20.9.0
24.10.0
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [v1.0.7] - 2026-03-03

### Added
- Added `isNone()` and `unwrap(result)` utility functions

### Changed
- Changed `NONE` to a unique `Symbol`, making it distinct from `succeed(null)`

## [v1.0.6] - 2025-08-13

### Added
Expand Down
75 changes: 60 additions & 15 deletions attempt.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,19 @@ const ERROR_INDEX = 1;

const OK_INDEX = 2;

export const NONE = null;
/**
* @type {unique symbol}
*/
export const NONE = Symbol('attempt:value:none');

/**
* @type {unique symbol}
*/
export const SUCCEEDED = Symbol('attempt:status:succeeded');

/**
* @type {unique symbol}
*/
export const FAILED = Symbol('attempt:status:failed');

/**
Expand All @@ -27,7 +37,7 @@ export const ATTEMPT_STATUSES = Object.freeze({

/**
* @template T
* @template E
* @template {AnyError} E
* @typedef {AttemptSuccess<T> | AttemptFailure<E>} AttemptResult<T, E>
* Union type for both possible attempt outcomes.
*/
Expand All @@ -37,7 +47,7 @@ class ResultTuple extends Array {
* @param {E} error
* @param {boolean} ok
*/
constructor(value, error, ok) {
constructor(value, error, ok = value !== NONE) {
if (new.target === ResultTuple) {
throw new TypeError('Cannot construct `ResultTuple` instances directly. Use `succeed()` or `fail()` instead.');
}
Expand All @@ -47,6 +57,10 @@ class ResultTuple extends Array {
Object.freeze(this);
}

get [Symbol.toStringTag]() {
return 'ResultTuple';
}

toString() {
return `[object ${this[Symbol.toStringTag]}]`;
}
Expand Down Expand Up @@ -98,7 +112,11 @@ class SuccessTuple extends ResultTuple {
* @param {T} value
*/
constructor(value) {
super(value, NONE, true);
if (value === NONE) {
throw new TypeError('Cannot succeed with `NONE` as value.');
} else {
super(value, NONE, true);
}
}

get [Symbol.toStringTag]() {
Expand All @@ -115,7 +133,7 @@ class SuccessTuple extends ResultTuple {

/**
* @template E
* * @typedef {readonly [NONE, E, false] & { value: NONE, error: E, status: typeof FAILED, ok: false }} AttemptFailure
* @typedef {readonly [NONE, E, false] & { value: NONE, error: E, status: typeof FAILED, ok: false }} AttemptFailure
* Represents a failed outcome tuple with hidden metadata. Named differently in class to avoid JSDocs confusion.
*/
class FailureTuple extends ResultTuple {
Expand All @@ -124,18 +142,20 @@ class FailureTuple extends ResultTuple {
* @param {E} error
*/
constructor(error) {
if (typeof error === 'string') {
if (error === NONE) {
throw new TypeError('Cannot fail with `NONE` as error.');
} else if (typeof error === 'string') {
super(NONE, new Error(error), false);
} else if (Error.isError(error)) {
super(NONE, error, false);
} else if (! (error instanceof AbortSignal)) {
super(NONE, new TypeError('Invalid error type provided.'), false);
} else if (! error.aborted) {
super(NONE, new TypeError('Failed with a non-aborted `AbortSignal`.'), false);
} else if (typeof error.reason === 'string') {
super(NONE, new Error(error.reason), false);
} else {
} else if (Error.isError(error.reason)) {
super(NONE, error.reason, false);
} else {
super(NONE, new Error(error.reason), false);
}
}

Expand Down Expand Up @@ -183,6 +203,13 @@ export const isAttemptResult = result => result instanceof ResultTuple;
*/
export const succeeded = result => result instanceof SuccessTuple;

/**
*
* @param {any} val The value to check
* @returns {boolean} If the value is `NONE`
*/
export const isNone = val => val === NONE;

/**
* Returns `true` if the given result is a failed AttemptResult.
*
Expand Down Expand Up @@ -230,6 +257,24 @@ export function fail(err) {
}
}

/**
*
* @template T
* @template {AnyError} E
* @param {AttemptResult<T, E>} result
* @returns {T}
* @throws {E}
*/
export function unwrap(result) {
if (! (result instanceof ResultTuple)) {
throw new TypeError('Cannot unwrap a non-Result object.');
} else if (result.ok) {
return result[VALUE_INDEX];
} else {
throw result[ERROR_INDEX];
}
}

/**
* Extracts the value from a successful `AttemptResult`.
*
Expand All @@ -240,7 +285,7 @@ export function fail(err) {
*/
export function getResultValue(result) {
if (result instanceof SuccessTuple) {
return result.value;
return result[VALUE_INDEX];
} else {
throw new TypeError('Result must be an `AttemptSuccess` tuple.');
}
Expand All @@ -256,7 +301,7 @@ export function getResultValue(result) {
*/
export function getResultError(result) {
if (result instanceof FailureTuple){
return result.error;
return result[ERROR_INDEX];
} else {
throw new TypeError('Result must be an `AttemptFailure` tuple.');
}
Expand Down Expand Up @@ -295,7 +340,7 @@ export async function attemptAsync(callback, ...args) {
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function.');
} else {
return await Promise.try(callback, ...args).then(succeed).catch(fail);
return await Promise.try(callback, ...args).then(succeed, fail);
}
}

Expand All @@ -322,7 +367,7 @@ export function attemptSync(callback, ...args) {
try {
const result = callback(...args);

return succeed(result);
return result instanceof Promise ? result.then(succeed, fail) : succeed(result);
} catch(err) {
return fail(err);
}
Expand Down Expand Up @@ -438,6 +483,7 @@ export function handleResultSync(result, {
/**
* Attempts to execute multiple callbacks sequentially, passing the result of each callback to the next.
*
* @template T,R
* @param {...Function} callbacks
* @returns {Promise<AttemptSuccess<any>|AttemptFailure<Error>>}
*/
Expand All @@ -447,7 +493,6 @@ export async function attemptAll(...callbacks) {
} else {
return await callbacks.reduce(
/**
* @template T,R
* @param {Promise<T>} promise
* @param {(T) => R|PromiseLike<R>} callback
* @returns {Promise<R>}
Expand All @@ -460,7 +505,7 @@ export async function attemptAll(...callbacks) {

/**
* Throws the error if `result` is an `AttemptFailure`.
*
* @template E
* @param {AttemptResult<any, E>} result The result tuple
* @throws {E} The error if result is an `AttemptFailure`
*/
Expand Down
31 changes: 21 additions & 10 deletions attempt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import '@shgysk8zer0/polyfills';
import { describe, test } from 'node:test';
import { ok, strictEqual, doesNotReject, rejects, throws, deepStrictEqual, fail as failTest, doesNotThrow } from 'node:assert';
import {
attemptAsync, attemptSync, createSafeSyncCallback, createSafeAsyncCallback, succeed, fail, succeeded,
attemptAsync, attemptSync, createSafeSyncCallback, createSafeAsyncCallback, succeed, fail, succeeded, unwrap,
failed, isAttemptResult, getResultError, getResultValue, handleResultAsync, handleResultSync, throwIfFailed,
getAttemptStatus, SUCCEEDED, FAILED, attemptAll, AttemptSuccess, AttemptFailure, AttemptResult,
getAttemptStatus, SUCCEEDED, FAILED, attemptAll, AttemptSuccess, AttemptFailure, AttemptResult, NONE, isNone,
} from './attempt.js';

describe('Test `attempt` library', async () => {
Expand Down Expand Up @@ -44,6 +44,8 @@ describe('Test `attempt` library', async () => {

throws(() => new AttemptResult('true', 'false', false), 'Should not be able to construct `AttemptResult` directly.');
ok(good.ok, 'Successful results should have `ok` set to `true`.');
ok(isNone(good.error), 'Success results should have `NONE` for error.');
ok(isNone(bad.value), 'Failed results should have `NONE` for value.');
ok(! bad.ok, 'Failed results should have `ok` set to `false`.');
ok(good instanceof AttemptSuccess, '`succeed()` should return an `AttemptSuccess` object/tuple.');
ok(bad instanceof AttemptFailure, '`fail()` should return an `AttemptFailure` object/tuple.');
Expand Down Expand Up @@ -71,8 +73,8 @@ describe('Test `attempt` library', async () => {
const [result2, err2] = fail('This should error.');

strictEqual(value, 'This should succeed.', '`succeed()` should have the expected result.');
strictEqual(error, null, '`succeed()` should not return an error.');
strictEqual(result2, null, '`fail()` should not return a result.');
strictEqual(error, NONE, '`succeed()` should not return an error.');
strictEqual(result2, NONE, '`fail()` should not return a result.');
ok(err2 instanceof Error, '`fail()` should return an error.');
});

Expand All @@ -83,12 +85,21 @@ describe('Test `attempt` library', async () => {

ok(passed1, '3rd element should be a boolean and `true` on successful attempts.');
ok(! passed2, '3rd element should be a boolean and `false` on failed attempts.');
strictEqual(error1, null, 'Successful path should not return an error.');
strictEqual(error1, NONE, 'Successful path should not return an error.');
strictEqual(result1, msg, 'Returned result should match expectations.');
strictEqual(result2, null, 'Failed attempts should not return a value.');
strictEqual(result2, NONE, 'Failed attempts should not return a value.');
ok(error2 instanceof Error, 'Failed attempts should return an error.');
});

test('Test unwrapping of results.', { signal }, () => {
const err = new Error('Failed');
const passed = succeed('success');
const failed = fail(err);
throws(() => unwrap(failed), 'Failed results should throw when unwrapped.');
doesNotThrow(() => unwrap(passed), 'Successful results should not throw when unwrapped.');
strictEqual(unwrap(passed), passed.value, 'Unwrapping should return the value.');
});

test('`attemptAsync()` should throw if callback is not a function.', { signal }, async () => {
await rejects(() => attemptAsync('Not a function.'), '`attemptAsync()` should throw if callback is not a function.');
});
Expand All @@ -105,8 +116,8 @@ describe('Test `attempt` library', async () => {
const [failed, error] = parse('{Invalid JSON}');

deepStrictEqual(parsed, data, 'Safe callbacks should return expected results.');
strictEqual(err, null, 'Successfull callbacks should not return an error.');
strictEqual(failed, null, 'Errored callbacks should not return results.');
strictEqual(err, NONE, 'Successfull callbacks should not return an error.');
strictEqual(failed, NONE, 'Errored callbacks should not return results.');
ok(error instanceof Error, 'Failed callbacks should return an error.');
});

Expand All @@ -117,8 +128,8 @@ describe('Test `attempt` library', async () => {
const [failed, error] = await parse('{Invalid JSON}');

deepStrictEqual(parsed, data, 'Safe callbacks should return expected results.');
strictEqual(err, null, 'Successfull callbacks should not return an error.');
strictEqual(failed, null, 'Errored callbacks should not return results.');
strictEqual(err, NONE, 'Successfull callbacks should not return an error.');
strictEqual(failed, NONE, 'Errored callbacks should not return results.');
ok(error instanceof Error, 'Failed callbacks should return an error.');
throws(() => throwIfFailed(handleResultSync(err, {})), 'Default error handle should return an `AttemptFailure`.');
});
Expand Down
Loading
Loading