Repository: true-myth/true-myth
Pull request #911 (2025-01-09 03:39:29)

Task: introduce `safeNullable` function
Because we cannot trivially compose the `safe` functions from the `task` and `maybe` modules, courtesy of the intermediate `Promise`, we need a dedicated handler for this scenario.


Patch:
diff --git a/src/task.ts b/src/task.ts
index 4052eae..83e2d9c 100644
--- a/src/task.ts
+++ b/src/task.ts
@@ -5,6 +5,7 @@
  */
 
 import { curry1, safeToString } from './-private/utils.js';
+import Maybe from './maybe.js';
 import Result, { map as mapResult, mapErr, match as matchResult } from './result.js';
 import Unit from './unit.js';
 
@@ -1449,7 +1450,6 @@ 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
@@ -1507,3 +1507,99 @@ export function safe<
   let handleError = onError ?? ((e: unknown) => e);
   return (...params) => safelyTryOrElse(handleError, () => fn(...params) as R);
 }
+
+/**
+  Given a function which returns a `Promise` of a nullable type, return
+  a new function with the same parameters but which returns a {@linkcode
+  Task} of a {@linkcode Maybe} instead.
+
+  If you wish to transform the error directly, rather than with a combinator,
+  see the other overload, which accepts an error handler.
+
+  This is basically just a convenience for something you could do yourself by
+  chaining `safe` with `Maybe.of`:
+
+  ```ts
+  import Maybe from 'true-myth/maybe';
+  import { safe, safeNullable } from 'true-myth/task';
+
+  async function numberOrNull(value: number): Promise<number | null> {
+    return Math.random() > 0.5 ? value : null;
+  }
+
+  // Using this helper
+  const safeNumberOrNull = safeNullable(numberOrNull);
+
+  // Using `safe` and `Maybe.of` manually
+  const moreWorkThisWay= safe(numberOrNull);
+  let theTask = moreWorkThisWay(123).map((n) => Maybe.of(n));
+  ```
+
+  The convenience is high, though, since you can now use this to create fully
+  safe abstractions to use throughout your codebase, rather than having to
+  remember to do the additional call to `map` the `Task`’s resolution value into
+  a `Maybe` at each call site.
+
+  @param fn A function to wrap so it never throws an error or produces a
+    `Promise` rejection.
+*/
+export function safeNullable<
+  F extends (...params: never[]) => PromiseLike<unknown>,
+  P extends Parameters<F>,
+  R extends Awaited<ReturnType<F>>,
+>(fn: F): (...params: P) => Task<Maybe<NonNullable<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.
+
+  This is basically just a convenience for something you could do yourself by
+  chaining `safe` with `Maybe.of`:
+
+  ```ts
+  import Maybe from 'true-myth/maybe';
+  import { safe, safeNullable } from 'true-myth/task';
+
+  async function numberOrNull(value: number): Promise<number | null> {
+    return Math.random() > 0.5 ? value : null;
+  }
+
+  // Using this helper
+  const safeNumberOrNull = safeNullable(numberOrNull);
+
+  // Using `safe` and `Maybe.of` manually
+  const moreWorkThisWay= safe(numberOrNull);
+  let theTask = moreWorkThisWay(123).map((n) => Maybe.of(n));
+  ```
+
+  The convenience is high, though, since you can now use this to create fully
+  safe abstractions to use throughout your codebase, rather than having to
+  remember to do the additional call to `map` the `Task`’s resolution value into
+  a `Maybe` at each call site.
+
+  @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 safeNullable<
+  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<Maybe<NonNullable<R>>, E>;
+export function safeNullable<
+  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<Maybe<NonNullable<R>>, unknown> {
+  let handleError = onError ?? ((e: unknown) => e);
+  return (...params) =>
+    safelyTryOrElse(handleError, async () => {
+      let theValue = (await fn(...params)) as R;
+      return Maybe.of(theValue) as Maybe<NonNullable<R>>;
+    });
+}


Test patch:
diff --git a/test/task.test.ts b/test/task.test.ts
index adf73c9..59c1285 100644
--- a/test/task.test.ts
+++ b/test/task.test.ts
@@ -19,7 +19,9 @@ import Task, {
   safelyTryOr,
   safelyTryOrElse,
   safe,
+  safeNullable,
 } from 'true-myth/task';
+import Maybe from 'true-myth/maybe';
 import Result from 'true-myth/result';
 import Unit from 'true-myth/unit';
 import { unwrap, unwrapErr } from 'true-myth/test-support';
@@ -1993,6 +1995,148 @@ describe('module-scope functions', () => {
       });
     });
   });
+
+  describe('safeNullable', () => {
+    const ERROR_MESSAGE = 'the message';
+    const REJECTION_REASON = 'ugh';
+
+    function example(
+      value: number,
+      {
+        throwErr = false,
+        rejectPromise = false,
+        returnNull = false,
+      }: { throwErr?: boolean; rejectPromise?: boolean; returnNull?: boolean } = {
+        throwErr: false,
+        rejectPromise: false,
+        returnNull: false,
+      }
+    ): Promise<number | null> {
+      if (throwErr) {
+        throw new Error(ERROR_MESSAGE);
+      }
+
+      if (returnNull) {
+        return Promise.resolve(null);
+      }
+
+      return rejectPromise ? Promise.reject(REJECTION_REASON) : Promise.resolve(value);
+    }
+
+    describe('without an error handler', () => {
+      let safeExample = safeNullable(example);
+      expectTypeOf(safeExample).toEqualTypeOf<
+        (
+          value: number,
+          should?: { throwErr?: boolean; rejectPromise?: boolean; returnNull?: boolean }
+        ) => Task<Maybe<number>, unknown>
+      >();
+
+      test('when it throws', async () => {
+        let theTask = safeExample(123, { throwErr: true });
+        expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<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 with a value', async () => {
+          let theTask = safeExample(123);
+          expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, unknown>>();
+          await theTask;
+          if (theTask.isResolved) {
+            expect(theTask.value.isJust).toBe(true);
+            if (theTask.value.isJust) {
+              expect(theTask.value.value).toBe(123);
+            }
+          } else {
+            expect.unreachable();
+          }
+        });
+
+        test('and it resolves with null', async () => {
+          let theTask = safeExample(123, { returnNull: true });
+          expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, unknown>>();
+          await theTask;
+          if (theTask.isResolved) {
+            expect(theTask.value.isNothing).toBe(true);
+          } else {
+            expect.unreachable();
+          }
+        });
+
+        test('and it rejects', async () => {
+          let theTask = safeExample(123, { rejectPromise: true });
+          expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, unknown>>();
+          await theTask;
+          if (theTask.isRejected) {
+            expect(theTask.reason).toBe(REJECTION_REASON);
+          } else {
+            expect.unreachable();
+          }
+        });
+      });
+    });
+
+    describe('with an error handler', () => {
+      let safeExample = safeNullable(example, stringify);
+      expectTypeOf(safeExample).toEqualTypeOf<
+        (
+          value: number,
+          should?: { throwErr?: boolean; rejectPromise?: boolean; returnNull?: boolean }
+        ) => Task<Maybe<number>, string>
+      >();
+
+      test('when it throws', async () => {
+        let theTask = safeExample(123, { throwErr: true });
+        expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, string>>();
+        await theTask;
+        if (theTask.isRejected) {
+          expect(theTask.reason).toBe('{}'); // Errors stringify weirdly
+        }
+      });
+
+      describe('when it does not throw', () => {
+        test('and it resolves with a value', async () => {
+          let theTask = safeExample(123);
+          expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, string>>();
+          await theTask;
+          if (theTask.isResolved) {
+            expect(theTask.value.isJust).toBe(true);
+            if (theTask.value.isJust) {
+              expect(theTask.value.value).toBe(123);
+            }
+          } else {
+            expect.unreachable();
+          }
+        });
+
+        test('and it resolves with null', async () => {
+          let theTask = safeExample(123, { returnNull: true });
+          expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, string>>();
+          await theTask;
+          if (theTask.isResolved) {
+            expect(theTask.value.isNothing).toBe(true);
+          } else {
+            expect.unreachable();
+          }
+        });
+
+        test('and it rejects', async () => {
+          let theTask = safeExample(123, { rejectPromise: true });
+          expectTypeOf(theTask).toEqualTypeOf<Task<Maybe<number>, string>>();
+          await theTask;
+          if (theTask.isRejected) {
+            expect(theTask.reason).toBe(`"${REJECTION_REASON}"`);
+          } else {
+            expect.unreachable();
+          }
+        });
+      });
+    });
+  });
 });
 
 describe('type utilities', () => {