Skip to content

Commit 090d969

Browse files
authored
Merge pull request #11 from AegisJSProject/feature/tuple
feature/tuple
2 parents 0cce30d + ec5afb1 commit 090d969

File tree

5 files changed

+155
-92
lines changed

5 files changed

+155
-92
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [v1.0.4] - 2025-08-05
11+
12+
### Changed
13+
- Refactor to use `AttemptResult` & `AttemtFailure` classes extending `Array` for better type safety and clarity.
14+
- Improved type definitions for `AttemptSuccess` and `AttemptFailure`.
15+
1016
## [v1.0.3] - 2025-07-30
1117

1218
### Added

attempt.js

Lines changed: 130 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ const VALUE_INDEX = 0;
1010

1111
const ERROR_INDEX = 1;
1212

13+
export const NONE = null;
14+
1315
export const SUCCEEDED = Symbol('attempt:status:succeeded');
1416
export const FAILED = Symbol('attempt:status:failed');
1517

16-
1718
/**
1819
* Returns the status of an attempt result.
1920
* @enum {unique symbol}
@@ -25,81 +26,111 @@ export const ATTEMPT_STATUSES = Object.freeze({
2526
failed: FAILED,
2627
});
2728

28-
/**
29-
* Gets the status of an attempt result.
30-
*
31-
* @param {AttemptResult} result The result to check.
32-
* @returns {ATTEMPT_STATUSES.succeeded|ATTEMPT_STATUSES.failed}
33-
* @throws {TypeError} If the result is not an `AttemptResult`.
34-
*/
35-
export function getAttemptStatus(result) {
36-
if (isAttemptResult(result)) {
37-
return result[ATTEMPT_STATUS];
38-
} else {
39-
throw new TypeError('Result must be an `AttemptResult` tuple.');
40-
}
41-
}
42-
4329
/**
4430
* Union of all error types.
4531
* @typedef {Error|DOMException|TypeError|RangeError|AggregateError|ReferenceError|EvalError|URIError|SyntaxError} AnyError
4632
*/
4733

48-
/**
49-
* @template T
50-
* @typedef {readonly [T, null] & { [ATTEMPT_STATUS]: typeof ATTEMPT_STATUSES.succeeded }} AttemptSuccess
51-
* Represents a successful outcome tuple with hidden metadata.
52-
*/
53-
54-
/**
55-
* @template E
56-
* @typedef {readonly [null, E] & { [ATTEMPT_STATUS]: typeof ATTEMPT_STATUSES.failed }} AttemptFailure
57-
* Represents a failed outcome tuple with hidden metadata.
58-
*/
59-
6034
/**
6135
* @template T
6236
* @template E
6337
* @typedef {AttemptSuccess<T> | AttemptFailure<E>} AttemptResult<T, E>
6438
* Union type for both possible attempt outcomes.
6539
*/
40+
class ResultTuple extends Array {
41+
/**
42+
* @param {T} value
43+
* @param {E} error
44+
* @param {SUCCEEDED|FAILED} status
45+
*/
46+
constructor(value, error, status) {
47+
super(value, error);
48+
Object.defineProperty(this, ATTEMPT_STATUS, {
49+
value: status,
50+
writable: false,
51+
enumerable: false,
52+
configurable: false,
53+
});
54+
55+
Object.freeze(this);
56+
}
6657

67-
/**
68-
* Attach a hidden status symbol and freeze the result.
69-
*
70-
* @template T
71-
* @param {T} value A tuple to tag with metadata.
72-
* @param {symbol} status Internal status symbol.
73-
* @returns {readonly T & { [ATTEMPT_STATUS]: symbol }} The frozen and tagged tuple.
74-
* @private
75-
*/
76-
function _createResult(value, status) {
77-
Object.defineProperty(value, ATTEMPT_STATUS, {
78-
value: status,
79-
writable: false,
80-
enumerable: false,
81-
configurable: false,
82-
});
83-
84-
return Object.freeze(value);
58+
/**
59+
* @returns {Error|NONE}
60+
*/
61+
get error() {
62+
return this[ERROR_INDEX];
63+
}
64+
65+
/**
66+
* @returns {SUCCEEDED|FAILED}
67+
*/
68+
get status() {
69+
return this[ATTEMPT_STATUS];
70+
}
71+
72+
/**
73+
* @returns {T}
74+
*/
75+
get value() {
76+
return this[VALUE_INDEX];
77+
}
78+
79+
/**
80+
* @returns {typeof SUCCEEDED}
81+
*/
82+
static get SUCCEEDED() {
83+
return SUCCEEDED;
84+
}
85+
86+
/**
87+
* @returns {typeof FAILED}
88+
*/
89+
static get FAILED() {
90+
return FAILED;
91+
}
8592
}
8693

8794
/**
8895
* @template T
89-
* @param {AttemptSuccess<T>} result
90-
* @returns {T}
96+
* @typedef {readonly [T, NONE] & { [ATTEMPT_STATUS]: typeof ATTEMPT_STATUSES.succeeded, value: T, error: NONE, status: typeof ATTEMPT_STATUSES.succeeded }} AttemptSuccess
97+
* Represents a successful outcome tuple with hidden metadata. Named differently in class to avoid JSDocs confusion.
9198
*/
92-
function _extractValue(result) {
93-
return result[VALUE_INDEX];
99+
class SuccessTuple extends ResultTuple {
100+
/**
101+
*
102+
* @param {T} value
103+
*/
104+
constructor(value) {
105+
super(value, NONE, ResultTuple.SUCCEEDED);
106+
}
94107
}
95108

96109
/**
97110
* @template E
98-
* @param {AttemptFailure<E>} result
99-
* @returns {E}
111+
* * @typedef {readonly [NONE, E] & { [ATTEMPT_STATUS]: typeof ATTEMPT_STATUSES.failed, value: NONE, error: E, status: typeof ATTEMPT_STATUSES.failed }} AttemptFailure
112+
* Represents a failed outcome tuple with hidden metadata. Named differently in class to avoid JSDocs confusion.
100113
*/
101-
function _extractError(result) {
102-
return result[ERROR_INDEX];
114+
class FailureTuple extends ResultTuple {
115+
/**
116+
*
117+
* @param {E} error
118+
*/
119+
constructor(error) {
120+
if (typeof error === 'string') {
121+
super(NONE, new Error(error), ResultTuple.FAILED);
122+
} else if (Error.isError(error)) {
123+
super(NONE, error, ResultTuple.FAILED);
124+
} else if (! (error instanceof AbortSignal)) {
125+
super(NONE, new TypeError('Invalid error type provided.'), ResultTuple.FAILED);
126+
} else if (! error.aborted) {
127+
super(NONE, new TypeError('Failed with a non-aborted `AbortSignal`.'), ResultTuple.FAILED);
128+
} else if (typeof error.reason === 'string') {
129+
super(NONE, new Error(error.reason), ResultTuple.FAILED);
130+
} else {
131+
super(NONE, error.reason, ResultTuple.FAILED);
132+
}
133+
}
103134
}
104135

105136
/**
@@ -124,23 +155,23 @@ const _failHandler = err => {
124155
* @param {unknown} result The value to check.
125156
* @returns {result is AttemptResult}
126157
*/
127-
export const isAttemptResult = result => Array.isArray(result) && Object.hasOwn(result, ATTEMPT_STATUS);
158+
export const isAttemptResult = result => result instanceof ResultTuple;
128159

129160
/**
130161
* Returns `true` if the given result is a successful AttemptResult.
131162
*
132163
* @param {unknown} result
133164
* @returns {result is AttemptSuccess}
134165
*/
135-
export const succeeded = result => isAttemptResult(result) && result[ATTEMPT_STATUS] === ATTEMPT_STATUSES.succeeded;
166+
export const succeeded = result => result instanceof SuccessTuple;
136167

137168
/**
138169
* Returns `true` if the given result is a failed AttemptResult.
139170
*
140171
* @param {unknown} result
141172
* @returns {result is AttemptFailure<AnyError>}
142173
*/
143-
export const failed = result => isAttemptResult(result) && result[ATTEMPT_STATUS] === ATTEMPT_STATUSES.failed;
174+
export const failed = result => result instanceof FailureTuple;
144175

145176
/**
146177
* Creates an `AttemptSuccess`
@@ -149,7 +180,7 @@ export const failed = result => isAttemptResult(result) && result[ATTEMPT_STATUS
149180
* @param {T} value
150181
* @returns {AttemptSuccess<T>}
151182
*/
152-
export const succeed = value => isAttemptResult(value) ? value : _createResult([value, null], ATTEMPT_STATUSES.succeeded);
183+
export const succeed = value => isAttemptResult(value) ? value : new SuccessTuple(value);
153184

154185
/**
155186
* @overload
@@ -174,14 +205,10 @@ export const succeed = value => isAttemptResult(value) ? value : _createResult([
174205
* @returns {AttemptFailure<AnyError>}
175206
*/
176207
export function fail(err) {
177-
if (isAttemptResult(err)) {
208+
if (err instanceof ResultTuple) {
178209
return err;
179-
} else if (Error.isError(err)) {
180-
return _createResult([null, err], ATTEMPT_STATUSES.failed);
181-
} else if (typeof err === 'string') {
182-
return _createResult([null, new Error(err)], ATTEMPT_STATUSES.failed);
183210
} else {
184-
return _createResult([null, new TypeError('Invalid error type provided.')], ATTEMPT_STATUSES.failed);
211+
return new FailureTuple(err);
185212
}
186213
}
187214

@@ -194,8 +221,8 @@ export function fail(err) {
194221
* @throws {TypeError} If the result is not a successful `AttemptSuccess`.
195222
*/
196223
export function getResultValue(result) {
197-
if (succeeded(result)) {
198-
return _extractValue(result);
224+
if (result instanceof SuccessTuple) {
225+
return result.value;
199226
} else {
200227
throw new TypeError('Result must be an `AttemptSuccess` tuple.');
201228
}
@@ -210,13 +237,28 @@ export function getResultValue(result) {
210237
* @throws {TypeError} If the result is not a failed `AttemptFailure`.
211238
*/
212239
export function getResultError(result) {
213-
if (failed(result)){
214-
return _extractError(result);
240+
if (result instanceof FailureTuple){
241+
return result.error;
215242
} else {
216243
throw new TypeError('Result must be an `AttemptFailure` tuple.');
217244
}
218245
}
219246

247+
/**
248+
* Gets the status of an attempt result.
249+
*
250+
* @param {AttemptResult} result The result to check.
251+
* @returns {ATTEMPT_STATUSES.succeeded|ATTEMPT_STATUSES.failed}
252+
* @throws {TypeError} If the result is not an `AttemptResult`.
253+
*/
254+
export function getAttemptStatus(result) {
255+
if (result instanceof ResultTuple) {
256+
return result.status;
257+
} else {
258+
throw new TypeError('Result must be an `AttemptResult` tuple.');
259+
}
260+
}
261+
220262
/**
221263
* Attempts to execute a given callback function, catching any synchronous errors or Promise rejections,
222264
* and returning the result in a consistent [value, error] tuple format.
@@ -226,9 +268,9 @@ export function getResultError(result) {
226268
* @template T
227269
* @param {(...any) => T | PromiseLike<T>} callback The function to execute. It can be synchronous or return a Promise.
228270
* @param {(...any)} args Arguments to pass to the callback function.
229-
* @returns {Promise<AttemptResult<Awaited<T>|null, AnyError|null>>} A Promise that resolves to a tuple:
230-
* - `[result, null]` on success, where `result` is the resolved value of `T`.
231-
* - `[null, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
271+
* @returns {Promise<AttemptResult<Awaited<T>|NONE, AnyError|NONE>>} A Promise that resolves to a tuple:
272+
* - `[result, NONE]` on success, where `result` is the resolved value of `T`.
273+
* - `[NONE, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
232274
* @throws {TypeError} If `callback` is not a function.
233275
*/
234276
export async function attemptAsync(callback, ...args) {
@@ -248,9 +290,9 @@ export async function attemptAsync(callback, ...args) {
248290
* @template T
249291
* @param {(...any) => T} callback The function to execute.
250292
* @param {(...any)} args Arguments to pass to the callback function.
251-
* @returns {AttemptResult<T, AnyError|null>} A tuple:
252-
* - `[result, null]` on success, where `result` is the value of `T`.
253-
* - `[null, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
293+
* @returns {AttemptResult<T, AnyError|NONE>} A tuple:
294+
* - `[result, NONE]` on success, where `result` is the value of `T`.
295+
* - `[NONE, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
254296
* @throws {TypeError} If `callback` is not a function or is an async function.
255297
*/
256298
export function attemptSync(callback, ...args) {
@@ -274,9 +316,9 @@ export function attemptSync(callback, ...args) {
274316
*
275317
* @template T
276318
* @param {(...any) => T | PromiseLike<T>} callback The function to execute.
277-
* @returns {(...any) => Promise<AttemptResult<Awaited<T>|null, AnyError|null>>>} An async wrapped function that returns to a tuple:
278-
* - `[result, null]` on success, where `result` is the value of `T`.
279-
* - `[null, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
319+
* @returns {(...any) => Promise<AttemptSuccess<Awaited<T>>|AttemptFailure<Error>>} A wrapped function that returns a tuple:
320+
* - `[result, NONE]` on success, where `result` is the value of `T`.
321+
* - `[NONE, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
280322
* @throws {TypeError} If `callback` is not a function.
281323
*/
282324
export const createSafeAsyncCallback = callback => async (...args) => await attemptAsync(callback, ...args);
@@ -286,9 +328,9 @@ export const createSafeAsyncCallback = callback => async (...args) => await atte
286328
*
287329
* @template T
288330
* @param {(...any) => T} callback The function to execute.
289-
* @returns {(...any) => AttemptResult<T, AnyError|null>} A wrapped function that returns a tuple:
290-
* - `[result, null]` on success, where `result` is the value of `T`.
291-
* - `[null, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
331+
* @returns {(...any) => AttemptSuccess<T>|AttemptFailure<Error>} A wrapped function that returns a tuple:
332+
* - `[result, NONE]` on success, where `result` is the value of `T`.
333+
* - `[NONE, Error]` on failure, where `Error` is the caught error (normalized to an Error object).
292334
* @throws {TypeError} If `callback` is not a function or is an async function.
293335
*/
294336
export const createSafeSyncCallback = callback => (...args) => attemptSync(callback, ...args);
@@ -313,7 +355,7 @@ export const createSafeSyncCallback = callback => (...args) => attemptSync(callb
313355
* failure?: (err: E) => V | PromiseLike<V>
314356
* signal?: AbortSignal<R>
315357
* }} callbacks Handlers for success or failure cases.
316-
* @returns {Promise<AttemptResult<Awaited<U>|Awaited<V>, E>|AttemptFailure<R>} A Promise resolving to a new `AttemptResult` from the callback execution,
358+
* @returns {Promise<AttemptSuccess<Awaited<U>|Awaited<V>, E>|AttemptFailure<R>} A Promise resolving to a new `AttemptResult` from the callback execution,
317359
* or a failure if the input is invalid.
318360
*/
319361
export async function handleResultAsync(result, {
@@ -326,7 +368,7 @@ export async function handleResultAsync(result, {
326368
} else if (typeof success !== 'function' || typeof failure !== 'function') {
327369
throw new TypeError('Both success and failure handlers must be functions.');
328370
} else if (signal instanceof AbortSignal && signal.aborted) {
329-
return fail(signal.reason instanceof Error ? signal.reason : new Error(signal.reason));
371+
return fail(signal.reason);
330372
} else {
331373
switch (getAttemptStatus(result)) {
332374
case ATTEMPT_STATUSES.succeeded:
@@ -386,7 +428,7 @@ export async function attemptAll(...callbacks) {
386428
if (callbacks.some(cb => typeof cb !== 'function')) {
387429
throw new TypeError('All callbacks must be functions.');
388430
} else {
389-
let result = succeed(null);
431+
let result = succeed(NONE);
390432

391433
for (const cb of callbacks) {
392434
if (result[ATTEMPT_STATUS] === ATTEMPT_STATUSES.failed) {
@@ -407,10 +449,13 @@ export async function attemptAll(...callbacks) {
407449
* @throws {AnyError} The error if result is an `AttemptFailure`
408450
*/
409451
export function throwIfFailed(result) {
410-
if (failed(result)) {
411-
throw getResultError(result);
452+
if (result instanceof FailureTuple) {
453+
throw result.error;
412454
}
413455
}
414456

415457
export const createSafeCallback = createSafeAsyncCallback;
416458
export const attempt = attemptAsync;
459+
460+
// Aliased to avoid confusion with types
461+
export { SuccessTuple as AttemptSuccess, FailureTuple as AttemptFailure };

0 commit comments

Comments
 (0)