nicolas-van/modern-async

Include a utility similiar to Async's "reflect"

Closed this issue · 5 comments

Please explain the feature or improvement you would like:
The "Async" library includes a utility call "reflect" which essentially wraps a callback with a try-catch. The resulting callback is then guaranteed to succeed with a results object that either contains the result of the function within a value property, or the returned error within a failure property. It would be incredibly useful to see the inclusion of a similar utility in modern-async that wraps promises or promise-generating methods.

Their example:

async.parallel([
    async.reflect(function(callback) {
        // do some stuff ...
        callback(null, 'one');
    }),
    async.reflect(function(callback) {
        // do some more stuff but error ...
        callback('bad stuff happened');
    }),
    async.reflect(function(callback) {
        // do some more stuff ...
        callback(null, 'two');
    })
],
// optional callback
function(err, results) {
    // values
    // results[0].value = 'one'
    // results[1].error = 'bad stuff happened'
    // results[2].value = 'two'
});

Please describe the use case where you would need that feature (the general situation or type of program where that would be helpful):"
"Reflect" would reduce the amount of boilerplate code required for error management when using functions like .map, which is otherwise pretty streamlined.

For example:

//  type ReflectResult<T> = {
//    value?: T;
//    error?: unknown;
//  };

function dangerouslyGetBirthday(username: string): Promise<Date> {}

const usernames = ["user1","user2"]
const birthdayResults: ReflectedResult<Date>[] = 
    async.map(usernames, reflect(username => dangerouslyGetBirthday(username)))

const birthdays = birthdayResults.map(result => result.value).filter(value => !!value)
const failures = birthdayResults.map(result => result.error).filter(error => !!error)

As for alternatives, I'm actually unsure what tools modern-async offers for error handling.

If you know another library similar to this one that already propose that feature please provide a link:
Async's Reflect

This was my go at it, and its integrated pretty well with the methods you offer. This allows you to wrap an iteratee with reflect, as well as wrap a standalone promise (not required for modern-async, but still useful for consistency).

/**
 * Wraps a promise so that it will always return a result object, regardless
 * of if it failed.
 */
type ReflectResult<T> = {
  value?: T;
  error?: unknown;
};
type ReflectedFunction<A extends unknown[], R> = (
  ...args: A
) => Promise<ReflectResult<R>>;

export function reflect<A extends unknown[], R>(
  fn: (...args: A) => Promise<R>,
): ReflectedFunction<A, R>;

export function reflect<T>(promise: Promise<T>): Promise<ReflectResult<T>>;

export function reflect(arg: unknown): unknown {
  const isFunction = (
    a: unknown,
  ): a is (...args: unknown[]) => Promise<unknown> => typeof a === 'function';
  const isPromise = <T = unknown>(a: unknown): a is Promise<T> =>
    !!a && typeof (a as Promise<T>)?.then === 'function';

  if (isFunction(arg)) {
    return reflectWrapper(arg);
  }

  if (isPromise(arg)) {
    return reflectPromise(arg);
  }

  throw new Error('Reflected object is neither a function nor promise');
}

function reflectWrapper<A extends unknown[], R>(
  fn: (...args: A) => Promise<R>,
): ReflectedFunction<A, R> {
  return (...args: A) => reflectPromise<R>(fn(...args));
}

function reflectPromise<T>(promise: Promise<T>): Promise<ReflectResult<T>> {
  return promise.then((val) => ({ value: val })).catch((e) => ({ error: e }));
}

Frankly I'm not a fan of it. I think the reflect function in async is just an heritage of the callback coding style and modern-async is all about getting rid of it in favor of async/await and promises.

Actually there is a lot of error handling in modern-async (it's probably the most difficult part of its code and the most tested area).

Each function documents the way it handles error. As example for map:

Produces a new collection of values by mapping each value in iterable through the iteratee function.

Multiple calls to iteratee will be performed in parallel.

If any of the calls to iteratee throws an exception the returned promise will be rejected.

Concretely the promise returned by map will be rejected with the first error it encounters in any of the underlying calls. It's done this way because if we want to iterate on a collection of X elements and perform the same operation, if any of the sub-operations fail the complete operation is also considered a failure.

That's exactly the same behavior if you iterate synchronously. Take this example:

const result = _.range(10).map((el) => {
  if (el === 5) {
    throw new Error('error)
  } else {
    return el;
  }
})

Here you will never get result, you'll get an exception instead. Same thing with modern-async's map (except the exception will be wrapped in a promise).

What you want to do here is to tolerate errors to let the whole process continue. And for that there isn't ten thousand solutions: just put a try/catch inside the iteratee. That's the same answer for all programming languages using exceptions, regardless if you use synchronous or asynchronous programming.

If want to have a complete state of all operations that failed or successed there is an easy-to-use pattern I use a lot:

const result = _.range(10).map((el) => {
  try {
    const res = doSomethingRisky(el)
    return ['value', res]
  } catch (e) {
    return ['error', e]
  }
})

result.map(([state, val], i) => {
  if (state === 'value') {
    console.log(`call ${i} succeeded with value ${val}`)
  } else {
    console.log(`call ${i} failed with error ${val}`)
  }
})

If you want to rewrite the same algorithm using modern-async it's extremely straightforward (basically it adds two await):

import {map} from 'modern-async'

const result = await map(_.range(10), (el) => {
  try {
    const res = await doSomethingRisky(el)
    return ['value', res]
  } catch (e) {
    return ['error', e]
  }
})

result.map(([state, val], i) => {
  if (state === 'value') {
    console.log(`call ${i} succeeded with value ${val}`)
  } else {
    console.log(`call ${i} failed with error ${val}`)
  }
})

Thanks for the response! Could you expand a bit on why the reflect function is strictly related to pre-async/await callbacks? I believe that the idea of standardizing a pattern for error tolerance naturally leads to a “reflect” like wrapper in aysnc/await as well.

The example you provide of error tolerance via a transformed "results" structure would eventually lead to the creation of a "reflect" wrapper once the try/catch -> Result<T> pattern is repeated.

type Result<T> = ['value': T] | ['error': unknown]

const result: Result<...>[] = await map(_.range(10), (el) => {
  try {
    const res = await doSomethingRisky(el)
    return ['value', res]
  } catch (e) {
    return ['error', e]
  }
}

const result2: Result<...>[] = await map(_.range(5), (el) => {
  try {
    const res = await doSomethingElseRisky(el)
    return ['value', res]
  } catch (e) {
    return ['error', e]
  }
}

// Seeing the above would have me create something reflect-like:
const wrapWithErrorHandling = async <T>(f: () => Promise<T>): Promise<Result<T>> {
  try {
    const result = await f()
    return ['value', result]
  } catch(e) {
    return ['error',e]
  }
}

const result1: Result<...>[] = await map(_.range(5), (el) => wrapWithErrorHandling(() => {
  return doSomethingElseRisky(el)
}))

The utility of the reflect code I posted is that it does a little more work to make integration easier, but otherwise its essentially the same as the wrapper above.

const result1: Result<...>[] = await map(_.range(5), reflect(doSomethingElseRisky))

A similar quality of life behavior can also be found in Promise.allSettled from ES2020

const values = await Promise.allSettled([
  Promise.resolve(33),
  new Promise(resolve => setTimeout(() => resolve(66), 0)),
  99,
  Promise.reject(new Error('an error'))
])
console.log(values)

// [
//   {status: "fulfilled", value: 33},
//   {status: "fulfilled", value: 66},
//   {status: "fulfilled", value: 99},
//   {status: "rejected",  reason: Error: an error}
// ]

I still believe it would be a nice optional utility to include in modern-async for applications where all-or-nothing approaches aren't ideal.

I didn't knew about Promise.allSettled. That's interesting as it kinds of lead to a standarization of the pattern I used to implement.

I think I'm gonna try to rewrite the internal code using Promise.allSettled's object specification instead of my own pattern and see if I could simplify the code using some helper. If I think this could lead to some good practice I will integrate it in the public API.

This feature was implemented in a new function named reflectAsyncStatus() and was published along version 2.0.0