Repository: true-myth/true-myth
Pull request #906 (2025-01-08 01:47:26)

Task: add `safe`, `safelyTry`, `safelyTryOr`, and `safelyTryOrElse`
- The `safe` function, like the just-landed function in the `result` module, transforms a function which produces a `Promise` into one that produces a `Task` instead, while otherwise maintaining the function's signature and semantics.

- The `safely` versions of `try`, `tryOr`, and `tryOrElse` require a function which produces the `Promise` from which the `Task` will be constructed, rather than accepting the `Promise` directly. This is a slight inconvenience, but it has an important upside: these versions of the functions can guarantee that they will handle and absorb any errors thrown during construction of the `Promise` as well.

Once these have been released, we can deprecate the existing `Task.try`, `Task.tryOr`, and `Task.tryOrElse` functions in favor of these functions and once we release 9.0, we can also remove them and rename the module- scope functions to have slightly nicer names (mostly without the prefix, although `try` cannot be used there), with the added benefit that the module will become more tree-shake-able that way.


Patch:
diff --git a/src/task.ts b/src/task.ts
index 028e23d..85cb01d 100644
--- a/src/task.ts
+++ b/src/task.ts
@@ -4,7 +4,7 @@
   @module
  */
 
-import { safeToString } from './-private/utils.js';
+import { curry1, safeToString } from './-private/utils.js';
 import Result, { map as mapResult, mapErr, match as matchResult } from './result.js';
 import Unit from './unit.js';
 
@@ -1330,3 +1330,171 @@ class Timeout extends Error {
 // Export *only* the type side (at least until we hear of a reason to do
 // otherwise): people ought not be subclassing or instantiating `Timeout`!
 export type { Timeout };
+
+/**
+  Given a function which takes no arguments and returns a `Promise`, return a
+  {@linkcode Task Task<T, unknown>} for the result of invoking that function.
+  This safely handles functions which fail synchronously or asynchronously, so
+  unlike {@linkcode Task.try} is safe to use with values which may throw errors
+  _before_ producing a `Promise`.
+
+  ## Examples
+
+  ```ts
+  import { safelyTry } from 'true-myth/task';
+
+  function throws(): Promise<T> {
+    throw new Error("Uh oh!");
+  }
+
+  // Note: passing the function by name, *not* calling it.
+  let theTask = safelyTry(throws);
+  let theResult = await theTask;
+  if (theResult.isErr) {
+    console.error((theResult.error as Error).message);
+  }
+  console.log(theResult.toString()); // Err(Error: Uh oh!)
+  ```
+
+  @param fn A function which returns a `Promise` when called.
+  @returns A `Task` which resolves to the resolution value of the promise or
+    rejects with the rejection value of the promise *or* any error thrown while
+    invoking `fn`.
+*/
+export function safelyTry<T>(fn: () => Promise<T>): Task<T, unknown> {
+  return new Task((resolve, reject) => {
+    try {
+      fn().then(resolve, reject);
+    } catch (e) {
+      reject(e);
+    }
+  });
+}
+
+export function safelyTryOr<T, E>(rejection: E): (fn: () => Promise<T>) => Task<T, E>;
+export function safelyTryOr<T, E>(rejection: E, fn: () => Promise<T>): Task<T, E>;
+export function safelyTryOr<T, E>(
+  rejection: E,
+  fn?: () => Promise<T>
+): Task<T, E> | ((fn: () => Promise<T>) => Task<T, E>) {
+  const op = (curriedFn: () => Promise<T>): Task<T, E> =>
+    new Task((resolve, reject) => {
+      try {
+        curriedFn().then(resolve, (_reason) => reject(rejection));
+      } catch (_e) {
+        reject(rejection);
+      }
+    });
+
+  return curry1(op, fn);
+}
+
+export function safelyTryOrElse<T, E>(
+  onError: (reason: unknown) => E
+): (fn: () => Promise<T>) => Task<T, E>;
+export function safelyTryOrElse<T, E>(
+  onError: (reason: unknown) => E,
+  fn: () => Promise<T>
+): Task<T, E>;
+export function safelyTryOrElse<T, E>(
+  onError: (reason: unknown) => E,
+  fn?: () => Promise<T>
+): Task<T, E> | ((fn: () => Promise<T>) => Task<T, E>) {
+  const op = (fn: () => Promise<T>): Task<T, E> =>
+    new Task((resolve, reject) => {
+      try {
+        fn().then(resolve, (reason) => reject(onError(reason)));
+      } catch (error) {
+        reject(onError(error));
+      }
+    });
+
+  return curry1(op, fn);
+}
+
+/**
+  Given a function which returns a `Promise`, return a new function with the
+  same parameters but which returns a {@linkcode Task} instead.
+
+  If you wish to transform the error directly, rather than with a combinator,
+  see the other overload, which accepts an error handler.
+
+  ## Examples
+
+  You can use this to create a safe version of the `fetch` function, which will
+  produce a `Task` instead of a `Promise` and which does not throw an error for
+  rejections, but instead produces a {@Rejected} variant of the `Task`.
+
+  ```ts
+  import { safe } from 'true-myth/task';
+
+  const fetch = safe(window.fetch);
+  const toJson = safe((response: Response) => response.json() as unknown);
+  let json = fetch('https://www.example.com/api/users').andThen(toJson);
+  ```
+
+  @param fn A function to wrap so it never throws an error or produces a
+    `Promise` rejection.
+*/
+export function safe<
+  F extends (...params: never[]) => unknown,
+  P extends Parameters<F>,
+  R extends Awaited<ReturnType<F>>,
+  E,
+>(fn: F): (...params: P) => Task<R, unknown>;
+/**
+  Given a function which returns a `Promise` and a function to transform thrown
+  errors or `Promise` rejections resulting from calling that function, return a
+  new function with the same parameters but which returns a {@linkcode Task}.
+
+  To catch all errors but leave them unhandled and `unknown`, see the other
+  overload.
+
+  ## Examples
+
+  You can use this to create a safe version of the `fetch` function, which will
+  produce a `Task` instead of a `Promise` and which does not throw an error for
+  rejections, but instead produces a {@Rejected} variant of the `Task`.
+
+  ```ts
+  import { safe } from 'true-myth/task';
+
+  class CustomError extends Error {
+    constructor(name: string, cause: unknown) {
+      super(`my-lib.error.${name}`, { cause });
+      this.name = name;
+    }
+  }
+
+  function handleErr(name: string): (cause: unknown) => CustomError {
+    return (cause) => new CustomError(name);
+  }
+
+  const fetch = safe(window.fetch, handleErr('fetch'));
+  const toJson = safe(
+    (response: Response) => response.toJson(),
+    handleErr('json-parsing')
+  );
+
+  let json = fetch('https://www.example.com/api/users').andThen(toJson);
+  ```
+
+  @param fn A function to wrap so it never throws an error or produces a
+    `Promise` rejection.
+  @param onError A function to use to transform the
+*/
+export function safe<
+  F extends (...params: never[]) => PromiseLike<unknown>,
+  P extends Parameters<F>,
+  R extends Awaited<ReturnType<F>>,
+  E,
+>(fn: F, onError: (reason: unknown) => E): (...params: P) => Task<R, E>;
+export function safe<
+  F extends (...params: never[]) => PromiseLike<unknown>,
+  P extends Parameters<F>,
+  R extends Awaited<ReturnType<F>>,
+  E,
+>(fn: F, onError?: (reason: unknown) => E): (...params: P) => Task<R, unknown> {
+  let handleError = onError ?? ((e: unknown) => e);
+  return (...params) => safelyTryOrElse(handleError, () => fn(...params) as R);
+}


Test patch:
diff --git a/test/task.test.ts b/test/task.test.ts
index 256008b..adf73c9 100644
--- a/test/task.test.ts
+++ b/test/task.test.ts
@@ -15,6 +15,10 @@ import Task, {
   Settled,
   AggregateRejection,
   Timer,
+  safelyTry,
+  safelyTryOr,
+  safelyTryOrElse,
+  safe,
 } from 'true-myth/task';
 import Result from 'true-myth/result';
 import Unit from 'true-myth/unit';
@@ -1751,6 +1755,244 @@ describe('module-scope functions', () => {
     let result = await aTimer;
     expect(unwrap(result)).toEqual(ms);
   });
+
+  describe('safelyTry', () => {
+    describe('with a non-throwing function', () => {
+      test('with a promise that resolves', async () => {
+        let theTask = safelyTry(() => Promise.resolve(123));
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, unknown>>();
+        let theResult = await theTask;
+        expect(unwrap(theResult)).toBe(123);
+      });
+
+      test('with a promise that rejects', async () => {
+        let theTask = safelyTry(() => Promise.reject(123));
+        expectTypeOf(theTask).toEqualTypeOf<Task<never, unknown>>();
+        let theResult = await theTask;
+        expect(unwrapErr(theResult)).toBe(123);
+      });
+    });
+
+    describe('with a throwing function', () => {
+      test('with a promise that resolves', async () => {
+        let theTask = safelyTry((): Promise<number> => {
+          throw new Error('NOPE');
+          return Promise.resolve(123);
+        });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, unknown>>();
+        let theResult = await theTask;
+        let theError = unwrapErr(theResult);
+        expect(theError).toBeInstanceOf(Error);
+        expect((theError as Error).message).toBe('NOPE');
+      });
+
+      test('with a promise that rejects', async () => {
+        let theTask = safelyTry((): Promise<number> => {
+          throw new Error('NOPE');
+          return Promise.reject(123);
+        });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, unknown>>();
+        let theResult = await theTask;
+        let theError = unwrapErr(theResult);
+        expect(theError).toBeInstanceOf(Error);
+        expect((theError as Error).message).toBe('NOPE');
+      });
+    });
+  });
+
+  describe('safelyTryOr', () => {
+    describe('with a non-throwing function', () => {
+      test('with a promise that resolves', async () => {
+        let theTask = safelyTryOr('error', () => Promise.resolve(123));
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        let theResult = await theTask;
+        expect(unwrap(theResult)).toBe(123);
+      });
+
+      test('with a promise that rejects', async () => {
+        let theTask = safelyTryOr('error', () => Promise.reject(123));
+        expectTypeOf(theTask).toEqualTypeOf<Task<never, string>>();
+        let theResult = await theTask;
+        expect(unwrapErr(theResult)).toBe('error');
+      });
+    });
+
+    describe('with a throwing function', () => {
+      test('with a promise that resolves', async () => {
+        let theTask = safelyTryOr('error', (): Promise<number> => {
+          throw new Error('NOPE');
+          return Promise.resolve(123);
+        });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        let theResult = await theTask;
+        expect(unwrapErr(theResult)).toBe('error');
+      });
+
+      test('with a promise that rejects', async () => {
+        let theTask = safelyTryOr('error', (): Promise<number> => {
+          throw new Error('NOPE');
+          return Promise.reject(123);
+        });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        let theResult = await theTask;
+        expect(unwrapErr(theResult)).toBe('error');
+      });
+    });
+  });
+
+  describe('safelyTryOrElse', () => {
+    describe('with a non-throwing function', () => {
+      test('with a promise that resolves', async () => {
+        let theTask = safelyTryOrElse(stringify, () => Promise.resolve(123));
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        let theResult = await theTask;
+        expect(unwrap(theResult)).toBe(123);
+      });
+
+      test('with a promise that rejects', async () => {
+        let theTask = safelyTryOrElse(stringify, () => Promise.reject(123));
+        expectTypeOf(theTask).toEqualTypeOf<Task<never, string>>();
+        let theResult = await theTask;
+        expect(unwrapErr(theResult)).toBe(stringify(123));
+      });
+    });
+
+    describe('with a throwing function', () => {
+      test('with a promise that resolves', async () => {
+        let theTask = safelyTryOrElse(stringify, (): Promise<number> => {
+          throw new Error('NOPE');
+          return Promise.resolve(123);
+        });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        let theResult = await theTask;
+        let theError = unwrapErr(theResult);
+        expect(theError).toBe(stringify(new Error('NOPE')));
+      });
+
+      test('with a promise that rejects', async () => {
+        let theTask = safelyTryOrElse(stringify, (): Promise<number> => {
+          throw new Error('NOPE');
+          return Promise.reject(123);
+        });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        let theResult = await theTask;
+        let theError = unwrapErr(theResult);
+        expect(theError).toBe(stringify(new Error('NOPE')));
+      });
+    });
+  });
+
+  describe('safe', () => {
+    const ERROR_MESSAGE = 'the message';
+    const REJECTION_REASON = 'ugh';
+
+    function example(
+      value: number,
+      {
+        throwErr = false,
+        rejectPromise = false,
+      }: { throwErr?: boolean; rejectPromise?: boolean } = {
+        throwErr: false,
+        rejectPromise: false,
+      }
+    ): Promise<number> {
+      if (throwErr) {
+        throw new Error(ERROR_MESSAGE);
+      }
+
+      return rejectPromise ? Promise.reject(REJECTION_REASON) : Promise.resolve(value);
+    }
+
+    describe('without an error handler', () => {
+      let safeExample = safe(example);
+      expectTypeOf(safeExample).toEqualTypeOf<
+        (
+          value: number,
+          should?: { throwErr?: boolean; rejectPromise?: boolean }
+        ) => Task<number, unknown>
+      >();
+
+      test('when it throws', async () => {
+        let theTask = safeExample(123, { throwErr: true });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, unknown>>();
+        await theTask;
+        if (theTask.isRejected) {
+          expect((theTask.reason as Error).message).toMatch(ERROR_MESSAGE);
+        }
+      });
+
+      describe('when it does not throw', () => {
+        test('and it resolves', async () => {
+          let theTask = safeExample(123);
+          expectTypeOf(theTask).toEqualTypeOf<Task<number, unknown>>();
+          await theTask;
+          console.log(theTask.toString());
+          if (theTask.isResolved) {
+            expect(theTask.value).toBe(123);
+          } else {
+            expect.unreachable();
+          }
+        });
+
+        test('and it rejects', async () => {
+          let theTask = safeExample(123, { rejectPromise: true });
+          expectTypeOf(theTask).toEqualTypeOf<Task<number, unknown>>();
+          await theTask;
+          console.log(theTask.toString());
+          if (theTask.isRejected) {
+            expect(theTask.reason).toBe(REJECTION_REASON);
+          } else {
+            expect.unreachable();
+          }
+        });
+      });
+    });
+
+    describe('with an error handler', () => {
+      let safeExample = safe(example, stringify);
+      expectTypeOf(safeExample).toEqualTypeOf<
+        (
+          value: number,
+          should?: { throwErr?: boolean; rejectPromise?: boolean }
+        ) => Task<number, string>
+      >();
+
+      test('when it throws', async () => {
+        let theTask = safeExample(123, { throwErr: true });
+        expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+        await theTask;
+        if (theTask.isRejected) {
+          expect(theTask.reason).toBe('{}'); // Errors stringify weirdly
+        }
+      });
+
+      describe('when it does not throw', () => {
+        test('and it resolves', async () => {
+          let theTask = safeExample(123);
+          expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+          await theTask;
+          console.log(theTask.toString());
+          if (theTask.isResolved) {
+            expect(theTask.value).toBe(123);
+          } else {
+            expect.unreachable();
+          }
+        });
+
+        test('and it rejects', async () => {
+          let theTask = safeExample(123, { rejectPromise: true });
+          expectTypeOf(theTask).toEqualTypeOf<Task<number, string>>();
+          await theTask;
+          console.log(theTask.toString());
+          if (theTask.isRejected) {
+            expect(theTask.reason).toBe(`"${REJECTION_REASON}"`);
+          } else {
+            expect.unreachable();
+          }
+        });
+      });
+    });
+  });
 });
 
 describe('type utilities', () => {