typesafe-ts
Version:
TypeScript utilities for type-safe error handling and optional values
667 lines (664 loc) • 26.9 kB
JavaScript
/*
Copyright (c) 2025 Allan Deutsch
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const none_value = Symbol("None");
/**
* Error type returned by try_async operations when an async function throws or rejects.
* Contains comprehensive debugging information about the failed operation.
*/
class TryAsyncError extends Error {
constructor(operation, originalError) {
super(`Async operation failed: ${originalError instanceof Error
? originalError.message
: String(originalError)}`);
this.name = "TryAsyncError";
this.originalError = originalError;
this.operation = operation;
this.timestamp = Date.now();
// Only set cause if we have an Error
if (originalError instanceof Error) {
this.cause = originalError;
if (originalError.stack) {
this.stack = originalError.stack;
}
}
}
/** The original error/value that was thrown or caused the rejection */
originalError;
/** Description of the operation that failed */
operation;
/** Timestamp when the error occurred (milliseconds since epoch) */
timestamp;
}
class ResultImpl {
value;
error;
constructor(result) {
if ("ok" in result && !("error" in result)) {
this.value = result.ok;
this.error = none_value;
}
else if ("error" in result && !("ok" in result)) {
this.error = result.error;
this.value = none_value;
}
else {
// eslint-disable-next-line typesafe-ts/enforce-result-usage
throw new TypeError("Result must be constructed with either an 'ok' or 'error' property.");
}
}
get [Symbol.toStringTag]() {
return "Result";
}
is_ok() {
return this.value !== none_value;
}
is_error() {
return this.error !== none_value;
}
value_or(value_if_error) {
if (this.is_ok()) {
return this.value;
}
return value_if_error;
}
error_or(error_if_ok) {
if (this.is_error()) {
return this.error;
}
return error_if_ok;
}
map(fn) {
if (this.is_ok()) {
return result.ok(fn(this.value));
}
return this;
}
map_error(fn) {
if (this.is_error()) {
return result.error(fn(this.error));
}
return this;
}
map_err(fn) {
return this.map_error(fn);
}
match({ on_ok, on_error, }) {
if (this.is_ok()) {
return on_ok(this.value);
}
return on_error(this.error);
}
and_then(fn) {
if (this.is_ok()) {
return fn(this.value);
}
return this;
}
or_else(fn) {
if (this.is_error()) {
return fn(this.error);
}
return this;
}
*[Symbol.iterator]() {
if (this.is_ok()) {
yield this.value;
}
}
static ok(value) {
return new ResultImpl({ ok: value });
}
static error(error) {
return new ResultImpl({ error });
}
}
/**
* An awaitable wrapper for Result that enables immediate method chaining on async operations.
* AsyncResult implements PromiseLike and provides Result transformation methods like map, and_then, and or_else.
* The AsyncResult must be awaited to inspect the final result with `is_ok()` or `is_error()`.
*
* @template ResultType - The type of the ok value
* @template ErrorType - The type of the error (must extend Error)
*
* @example
* ```typescript
* // Chain operations and await the final result in one expression
* const finalResult = await result.try_async(() => fetchUser("123"))
* .map(user => user.name.toUpperCase())
* .and_then(name => name ? result.ok(name) : result.error(new Error("Empty name")))
* .or_else(() => result.ok("Unknown"));
* ```
*/
class AsyncResult {
promise;
/**
* Creates a new AsyncResult from a Promise that resolves to a Result.
*
* While `result.try_async()` is the preferred way to create AsyncResult instances,
* the constructor is useful when you have an async function that already returns
* a `Promise<Result>`. Wrapping it with `new AsyncResult()` provides access to
* the chaining API without requiring `await` and without "coloring" your function
* as async. This is particularly beneficial in contexts where `await` isn't allowed,
* such as top-level code or in component logic of some frontend frameworks.
*
* @param promise - A Promise that resolves to a Result
*
* @example
* ```typescript
* // Async function that returns Promise<Result>
* async function fetchUserData(id: string): Promise<Result<User, Error>> {
* return result.try_async(() => fetch(`/api/users/${id}`))
* .then(response => response.json());
* }
*
* // Without AsyncResult constructor: requires await, makes function async
* async function processUserAsync(id: string): Promise<Result<string, Error>> {
* const userData = await fetchUserData(id);
* return userData.map(user => user.name.toUpperCase());
* }
*
* // With AsyncResult constructor: no await needed, function stays sync
* function processUserSync(id: string): AsyncResult<string, Error> {
* return new AsyncResult(fetchUserData(id))
* .map(user => user.name.toUpperCase());
* }
*
* // Both usage patterns work the same way:
* const result1 = await processUserAsync("123");
* const result2 = await processUserSync("123");
* ```
*/
constructor(promise) {
this.promise = promise;
}
/**
* PromiseLike implementation equivalent to Promise.then. Allows AsyncResult to be awaited.
*
* @template TResult1 - The type returned when the promise resolves
* @template TResult2 - The type returned when the promise rejects
* @param onfulfilled - Callback executed when the AsyncResult resolves to a Result
* @param onrejected - Callback executed when the AsyncResult rejects
* @returns A PromiseLike that resolves to the result of the executed callback
*/
then(onfulfilled, onrejected) {
return this.promise.then(onfulfilled, onrejected);
}
get [Symbol.toStringTag]() {
return "Result";
}
/**
* Returns the contained value if Ok, otherwise returns the provided default value.
*
* @param value_if_error - The value to return if the Result contains an error
* @returns A Promise resolving to the contained value or the default value
*/
value_or(value_if_error) {
return this.promise.then((result) => result.value_or(value_if_error));
}
/**
* Access the contained error if this result is an error. Otherwise returns the provided default error.
*
* @param error_if_ok - The error to return if the Result contains a value
* @returns A Promise resolving to the contained error or the default error
*/
error_or(error_if_ok) {
return this.promise.then((result) => result.error_or(error_if_ok));
}
/**
* Transform the contained value if Ok, otherwise return the error unchanged.
*
* @template NewResultType - The type of the transformed value
* @param fn - Function to transform the value if Ok
* @returns A new AsyncResult with the transformed value if Ok, otherwise the original error
*/
map(fn) {
const newPromise = this.promise.then((result) => result.map(fn));
return new AsyncResult(newPromise);
}
/**
* Transform the contained error if Error, otherwise return the value unchanged.
*
* @template NewErrorType - The type of the transformed error
* @param fn - Function to transform the error if Error
* @returns A new AsyncResult with the transformed error if Error, otherwise the original value
*/
map_error(fn) {
const newPromise = this.promise.then((result) => result.map_error(fn));
return new AsyncResult(newPromise);
}
/**
* @deprecated Use map_error instead. This method will be removed in the next major version release.
*/
map_err(fn) {
return this.map_error(fn);
}
/**
* Pattern match against the Result, executing the appropriate callback and returning its result.
*
* @template OKMatchResultType - The return type of the on_ok callback
* @template ErrorMatchResultType - The return type of the on_error callback
* @param handlers - Object containing callback functions for Ok and Error cases
* @param handlers.on_ok - Function to execute if Result is Ok, receiving the value
* @param handlers.on_error - Function to execute if Result is Error, receiving the error
* @returns A Promise resolving to the result of the executed callback
*/
match({ on_ok, on_error, }) {
return this.promise.then((result) => result.match({ on_ok, on_error }));
}
/**
* Chain another Result-returning operation if this Result is Ok.
* If this Result is Error, the function is not called and the error is propagated.
*
* @template NewResultType - The type of the value in the returned Result
* @param fn - Function that takes the Ok value and returns a new Result
* @returns A new AsyncResult with the result returned by fn if Ok, otherwise the original error
*/
and_then(fn) {
const newPromise = this.promise.then((result) => result.and_then(fn));
return new AsyncResult(newPromise);
}
/**
* Provide a fallback Result if this Result is Error.
* If this Result is Ok, the function is not called and the value is preserved.
*
* @template NewErrorType - The type of error in the fallback Result
* @param fn - Function that takes the Error and returns a fallback Result
* @returns A new AsyncResult with the fallback Result returned by fn if Error, otherwise the original Ok value
*/
or_else(fn) {
const newPromise = this.promise.then((result) => result.or_else(fn));
return new AsyncResult(newPromise);
}
try_async(fn, errorMapper) {
if (errorMapper) {
// When error mapper is provided, return type excludes TryAsyncError
const newPromise = this.promise
.then(async (result) => {
if (result.is_error()) {
return result;
}
const value = await fn(result.value);
return ResultImpl.ok(value);
})
.catch((originalError) => {
return ResultImpl.error(errorMapper(originalError));
});
return new AsyncResult(newPromise);
}
else {
// When no error mapper is provided, return type includes TryAsyncError
const newPromise = this.promise
.then(async (result) => {
if (result.is_error()) {
return result;
}
const value = await fn(result.value);
return ResultImpl.ok(value);
})
.catch((originalError) => {
return ResultImpl.error(new TryAsyncError(fn.name || fn.toString(), originalError));
});
return new AsyncResult(newPromise);
}
}
/**
* Async iterator support. Yields the contained value if Ok, nothing if Error.
*
* @returns An async generator that yields the value if Ok, otherwise yields nothing
*
* @example
* ```typescript
* // Iterate over successful values
* for await (const value of result.try_async(() => fetchUser("123"))) {
* console.log(value.name); // Only executes if fetch succeeds
* }
*
* // Collect successful values from multiple async operations
* const users = [];
* for await (const user of result.try_async(() => fetchUser("456"))) {
* users.push(user);
* }
* ```
*/
async *[Symbol.asyncIterator]() {
const result = await this.promise;
for (const value of result) {
yield value;
}
}
}
function tryImpl(fn, errorMapper) {
// need to use try/catch to wrap throwing functions in results.
// eslint-disable-next-line typesafe-ts/enforce-result-usage
try {
return ResultImpl.ok(fn());
}
catch (error) {
if (errorMapper) {
return ResultImpl.error(errorMapper(error));
}
return ResultImpl.error(error instanceof Error ? error : new Error(String(error)));
}
}
function tryAsyncImpl(fn, errorMapper) {
const promise = (async () => {
// need to use try/catch to wrap throwing functions in a `Result`.
// eslint-disable-next-line typesafe-ts/enforce-result-usage
try {
const value = await fn();
return ResultImpl.ok(value);
}
catch (error) {
if (errorMapper) {
return ResultImpl.error(errorMapper(error));
}
return ResultImpl.error(error instanceof Error ? error : new Error(String(error)));
}
})();
return new AsyncResult(promise);
}
/**
* Factory functions for creating Result instances.
* This module provides the primary API for constructing Result values.
*
* @property {function} ok - Creates a successful Result containing the provided value
* @property {function} error - Creates a failed Result containing the provided error
* @property {function} ok_async - Creates an already-resolved AsyncResult containing the provided value
* @property {function} error_async - Creates an already-resolved AsyncResult containing the provided error
* @property {function} try - Executes a function and wraps the result in a Result type
* @property {function} try_async - Executes an async function and returns an awaitable AsyncResult that supports immediate chaining
* @property {function} retry - Retries a Result-returning function multiple times until success
* @property {function} retry_async - Retries an async Result-returning function multiple times until success
*
* @example
* ```typescript
* import { result, type Result } from "./result.ts";
*
* // Creating success results
* const success = result.ok("Hello, World!");
* const number = result.ok(42);
* const nullValue = result.ok(null);
*
* // Creating error results
* const failure = result.error(new Error("Something went wrong"));
* const customError = result.error(new TypeError("Type mismatch"));
*
* // Chaining operations
* const processed = result.ok(" hello ")
* .map(str => str.trim())
* .map(str => str.toUpperCase())
* .and_then(str => str.length > 0 ? result.ok(str) : result.error(new Error("Empty string")));
* ```
*/
const result = {
/**
* Creates a successful Result containing the provided value.
* The value can be of any type, including null and undefined.
*
* @template ResultType - The type of the success value
* @template ErrorType - The type of potential errors (defaults to Error)
* @param value - The value to wrap in a successful Result
* @returns A Result containing the provided value
*
* @example
* ```typescript
* const stringValue = result.ok("Hello");
* const numberValue = result.ok(42);
* const objectValue = result.ok({ name: "John", age: 30 });
* const nullValue = result.ok(null);
* const undefinedValue = result.ok(undefined);
*
* // All of these are Ok Results
* console.log(stringValue.is_ok()); // true
* console.log(numberValue.value_or(0)); // 42
* ```
*/
ok: (value) => ResultImpl.ok(value),
/**
* Creates a failed Result containing the provided error.
* The error must be an instance of Error or a subclass of Error.
*
* @template ResultType - The type of potential success values
* @template ErrorType - The type of the error (defaults to Error)
* @param error - The error to wrap in a failed Result
* @returns A Result containing the provided error
*
* @example
* ```typescript
* const basicError = result.error(new Error("Basic error"));
* const typeError = result.error<string, TypeError>(new TypeError("Wrong type"));
* const customError = result.error(new RangeError("Out of range"));
*
* // Explicitly typed error results
* const parseError: Result<number, Error> = result.error<number, Error>(new Error("Parse failed"));
* const validationError = result.error<User, ValidationError>(new ValidationError("Invalid data"));
*
* // All of these are Error Results
* console.log(basicError.is_error()); // true
* if (typeError.is_error()) {
* console.log(typeError.error.message); // "Wrong type"
* }
*
* // Custom error classes work too
* class ValidationError extends Error {
* field: string;
* constructor(message: string, field: string) {
* super(message);
* this.name = "ValidationError";
* this.field = field;
* }
* }
* ```
*/
error: (error) => ResultImpl.error(error),
/**
* Creates an AsyncResult that has already resolved to an Ok Result.
* Useful for synchronous functions that must return AsyncResult
* because one or more branches invoke async code.
*
* @template ResultType - The type of the success value
* @template ErrorType - The type of potential errors (defaults to Error)
* @param value - The non-Promise value to wrap in an already-resolved AsyncResult
* @returns An AsyncResult that resolves to an Ok Result containing the provided value
*
* @example
* ```typescript
* async function fetchOrFallback(id: string): AsyncResult<User, Error> {
* const cached = cache.get(id);
* if (cached) {
* return result.ok_async(cached);
* }
* return result.try_async(() => fetchUser(id));
* }
* ```
*/
ok_async: (value) => {
const promise = Promise.resolve(ResultImpl.ok(value));
return new AsyncResult(promise);
},
/**
* Creates an AsyncResult that has already resolved to an Error Result.
* Useful for synchronous functions that must return AsyncResult
* because one or more branches invoke async code.
*
* @template ResultType - The type of potential success values
* @template ErrorType - The type of the error (defaults to Error)
* @param error - The error to wrap in an already-resolved AsyncResult
* @returns An AsyncResult that resolves to an Error Result containing the provided error
*
* @example
* ```typescript
* async function createUser(input: UserInput): AsyncResult<User, ValidationError> {
* const validationIssue = validate(input);
* if (validationIssue) {
* return result.error_async<User, ValidationError>(validationIssue);
* }
* return result.try_async(() => persist(input));
* }
* ```
*/
error_async: (error) => {
const promise = Promise.resolve(ResultImpl.error(error));
return new AsyncResult(promise);
},
try: tryImpl,
try_async: tryAsyncImpl,
/**
* Retries a result-returning function until it succeeds or has failed for all of the requested retries.
* If the function returns an Ok Result, the retry operation stops and returns that successful Result.
* If the function returns an Error Result, it's retried up to the specified number of times.
* If all retries fail, returns an Error Result containing all the accumulated errors.
*
* @template ValueType - The type of the ok (success) value
* @template ErrorType - The type of the error (must extend Error)
* @param fn - Function that returns a Result and may be retried
* @param retries - Maximum number of attempts to make (0 means no attempts)
* @returns A Result containing either the successful value or a retry error with all accumulated errors
*
* @example
* ```typescript
* let attempts = 0;
* function unreliableOperation(): Result<string, Error> {
* attempts++;
* if (attempts < 3) {
* return result.error(new Error(`Attempt ${attempts} failed`));
* }
* return result.ok("Success!");
* }
*
* const retryResult = result.retry(unreliableOperation, 5);
* if (retryResult.is_ok()) {
* console.log(retryResult.value); // "Success!"
* }
*
* // Network request example
* function fetchData(): Result<string, Error> {
* // Simulated network request that might fail
* return Math.random() > 0.7
* ? result.ok("Data fetched successfully")
* : result.error(new Error("Network timeout"));
* }
*
* const networkResult = result.retry(fetchData, 3);
* networkResult.match({
* on_ok: (data) => console.log("Got data:", data),
* on_error: (error) => console.log("All retries failed:", error.errors.map(e => e.message))
* });
*
* // Can be chained with other Result operations
* const processedResult = result.retry(fetchData, 3)
* .map(data => data.toUpperCase())
* .and_then(data => data.includes("SUCCESS") ? result.ok(data) : result.error(new Error("Invalid data")));
* ```
*/
retry: (fn, retries) => {
if (typeof retries !== "number" || retries <= 0) {
return ResultImpl.error({
message: `Failed after 0 attempts.`,
name: "Result Retry Error",
errors: [],
});
}
const errors = [];
for (let i = 0; i < retries; i++) {
const result = fn();
if (result.is_ok()) {
return result;
}
else if (result.is_error()) {
errors.push(result.error);
}
}
return ResultImpl.error({
message: `Failed after ${retries} attempts.`,
name: "Result Retry Error",
errors: errors,
});
},
/**
* Retries a Promise<Result> returning function until it succeeds or has failed for all of the requested retries.
* If the function returns a Promise that resolves to an Ok Result, the retry operation stops and returns that successful Result.
* If the function returns a Promise that resolves to an Error Result, it's retried up to the specified number of times.
* If all retries fail, returns a Promise that resolves to an Error Result containing all the accumulated errors.
*
* @template ValueType - The type of the success value
* @template ErrorType - The type of the error (must extend Error)
* @param fn - Async function that returns a Promise<Result> and may be retried
* @param retries - Maximum number of attempts to make (0 means no attempts)
* @returns A Promise resolving to a Result containing either the successful value or a retry error with all accumulated errors
*
* @example
* ```typescript
* let attempts = 0;
* async function unreliableAsyncOperation(): Promise<Result<string, Error>> {
* attempts++;
* await new Promise(resolve => setTimeout(resolve, 100)); // Simulate async work
* if (attempts < 3) {
* return result.error(new Error(`Async attempt ${attempts} failed`));
* }
* return result.ok("Async success!");
* }
*
* const retryResult = await result.retry_async(unreliableAsyncOperation, 5);
* if (retryResult.is_ok()) {
* console.log(retryResult.value); // "Async success!"
* }
*
* // Network request example
* async function fetchDataAsync(): Promise<Result<string, Error>> {
* return result.try_async(async () => {
* const response = await fetch('/api/data');
* if (!response.ok) {
* throw new Error(`HTTP ${response.status}`);
* }
* const data = await response.text();
* return result.ok(data);
* })
* }
*
* const networkResult = await result.retry_async(fetchDataAsync, 3);
* networkResult.match({
* on_ok: (data) => console.log("Got data:", data),
* on_error: (error) => console.log("All retries failed:", error.errors.map(e => e.message))
* });
*
* // Can be chained with other Result operations
* const processedResult = await result.retry_async(fetchDataAsync, 3)
* .then(res => res.map(data => data.toUpperCase()))
* .then(res => res.and_then(data =>
* data.includes("SUCCESS") ? result.ok(data) : result.error(new Error("Invalid data"))
* ));
* ```
*/
retry_async: async (fn, retries) => {
if (typeof retries !== "number" || retries <= 0) {
return Promise.resolve(ResultImpl.error({
message: `Failed after 0 attempts.`,
name: "Result Retry Error",
errors: [],
}));
}
const errors = [];
for (let i = 0; i < retries; i++) {
const result_value = await fn();
if (result_value.is_ok()) {
return result_value;
}
else if (result_value.is_error()) {
errors.push(result_value.error);
}
}
return ResultImpl.error({
message: `Failed after ${retries} attempts.`,
name: "Result Retry Error",
errors: errors,
});
},
};
Object.freeze(result);
export { result, AsyncResult, };
//# sourceMappingURL=result.js.map