Repository: true-myth/true-myth
Pull request #905 (2025-01-07 23:22:56)

Result: add `safe` module-scoped function for wrapping throwing APIs
This is a complement to, not a replacement for, the `try*` APIs: those immediately produce a `Result` safely, whereas this produces a safe copy of the function to use repeatedly.


Patch:
diff --git a/src/result.ts b/src/result.ts
index f3a751c..2859d31 100644
--- a/src/result.ts
+++ b/src/result.ts
@@ -6,6 +6,7 @@
 
 import Unit from './unit.js';
 import { curry1, safeToString } from './-private/utils.js';
+import { AnyFunction } from './-private/utils.js';
 
 /**
   Discriminant for {@linkcode Ok} and {@linkcode Err} variants of the
@@ -1306,6 +1307,97 @@ export function ap<A, B, E>(
   return curry1(op, result);
 }
 
+/**
+  Transform a function which may throw an error into one with an identical call
+  signature except that it will return a {@linkcode} instead of throwing an
+  error.
+
+  This allows you to handle the error locally with all the normal `Result` tools
+  rather than having to catch an exception. Where the {@linkcode tryOr} and
+  {@linkcode tryOrElse} functions are useful for a single call, this is useful
+  to make a new version of a function to be used repeatedly.
+
+  This overload absorbs all exceptions into an {@linkcode Err} with the type
+  `unknown`. If you want to transform the error immediately rather than using a
+  combinator, see the other overload.
+
+  ## Examples
+
+  The `JSON.parse` method will throw if the string passed to it is invalid. You
+  can use this `safe` method to transform it into a form which will *not* throw:
+
+  ```ts
+  import { safe } from 'true-myth/task';
+  const parse = safe(JSON.parse);
+
+  let result = parse(`"ill-formed gobbledygook'`);
+  console.log(result.toString()); // Err(SyntaxError: Unterminated string in JSON at position 25)
+  ```
+
+  You could do this once in a utility module and then require that *all* JSON
+  parsing operations in your code use this version instead.
+
+  @param fn The function which may throw, which will be wrapped.
+*/
+export function safe<F extends AnyFunction, P extends Parameters<F>, R extends ReturnType<F>>(
+  fn: F
+): (...params: P) => Result<R, unknown>;
+/**
+  Transform a function which may throw an error into one with an identical call
+  signature except that it will return a {@linkcode} instead of throwing an
+  error.
+
+  This allows you to handle the error locally with all the normal `Result` tools
+  rather than having to catch an exception. Where the {@linkcode tryOr} and
+  {@linkcode tryOrElse} functions are useful for a single call, this is useful
+  to make a new version of a function to be used repeatedly.
+
+  This overload allows you to transform the error immediately, using the second
+  argument.
+
+  ## Examples
+
+  The `JSON.parse` method will throw if the string passed to it is invalid. You
+  can use this `safe` method to transform it into a form which will *not* throw,
+  wrapping it in a custom error :
+
+  ```ts
+  import { safe } from 'true-myth/task';
+
+  class ParsingError extends Error {
+    name = 'ParsingError';
+    constructor(error: unknown) {
+      super('Parsing error.', { cause: error });
+    }
+  }
+
+  const parse = safe(JSON.parse, (error) => {
+    return new ParsingError(error);
+  });
+
+  let result = parse(`"ill-formed gobbledygook'`);
+  console.log(result.toString()); // Err(SyntaxError: Unterminated string in JSON at position 25)
+  ```
+
+  You could do this once in a utility module and then require that *all* JSON
+  parsing operations in your code use this version instead.
+
+  @param fn The function which may throw, which will be wrapped.
+  @param handleErr A function to use to transform an unknown error into a known
+    error type.
+*/
+export function safe<F extends AnyFunction, P extends Parameters<F>, R extends ReturnType<F>, E>(
+  fn: F,
+  handleErr: (error: unknown) => E
+): (...params: P) => Result<R, E>;
+export function safe<F extends AnyFunction, P extends Parameters<F>, R extends ReturnType<F>, E>(
+  fn: F,
+  handleErr?: (error: unknown) => E
+): (...params: P) => Result<R, unknown> {
+  let errorHandler = handleErr ?? ((e) => e);
+  return (...params) => tryOrElse(errorHandler, () => fn(...params) as R);
+}
+
 /**
   Determine whether an item is an instance of {@linkcode Result}.
 


Test patch:
diff --git a/test/result.test.ts b/test/result.test.ts
index aefdc20..34a5c76 100644
--- a/test/result.test.ts
+++ b/test/result.test.ts
@@ -444,6 +444,72 @@ describe('`Result` pure functions', () => {
 
     expect(ResultNS.isErr(testErr)).toEqual(true);
   });
+
+  describe('`safe` function', () => {
+    const THE_MESSAGE = 'the error message';
+
+    function example(
+      value: number,
+      { throwErr: shouldThrow = false }: { throwErr?: boolean } = { throwErr: false }
+    ): string {
+      if (shouldThrow) {
+        throw new Error(THE_MESSAGE);
+      }
+
+      return `value was ${value}`;
+    }
+
+    describe('without an error handler', () => {
+      let safeExample = ResultNS.safe(example);
+      expectTypeOf(safeExample).toEqualTypeOf<
+        (value: number, should?: { throwErr?: boolean }) => Result<string, unknown>
+      >();
+
+      test('when it throws', () => {
+        let theResult = safeExample(123, { throwErr: true });
+        if (theResult.isErr) {
+          expect(theResult.error).toBeInstanceOf(Error);
+          expect((theResult.error as Error).message).toBe(THE_MESSAGE);
+        } else {
+          expect.unreachable();
+        }
+      });
+
+      test('when it does not throw', () => {
+        let theResult = safeExample(123);
+        if (theResult.isOk) {
+          expect(theResult.value).toBe('value was 123');
+        } else {
+          expect.unreachable();
+        }
+      });
+    });
+
+    describe('with an error handler', () => {
+      let safeExample = ResultNS.safe(example, (reason) => JSON.stringify(reason, null, 2));
+      expectTypeOf(safeExample).toEqualTypeOf<
+        (value: number, should?: { throwErr?: boolean }) => Result<string, string>
+      >();
+
+      test('when it throws', () => {
+        let theResult = safeExample(123, { throwErr: true });
+        if (theResult.isErr) {
+          expect(theResult.error).toBe('{}'); // stringify is weird
+        } else {
+          expect.unreachable();
+        }
+      });
+
+      test('when it does not throw', () => {
+        let theResult = safeExample(123);
+        if (theResult.isOk) {
+          expect(theResult.value).toBe('value was 123');
+        } else {
+          expect.unreachable();
+        }
+      });
+    });
+  });
 });
 
 // We aren't even really concerned with the "runtime" behavior here, which we