Skip to content

Instantly share code, notes, and snippets.

@tjconcept
Last active September 13, 2022 22:16
Show Gist options
  • Save tjconcept/69994c78c8f5b8008258353292787519 to your computer and use it in GitHub Desktop.
Save tjconcept/69994c78c8f5b8008258353292787519 to your computer and use it in GitHub Desktop.
"join" with deterministic behavior for rejections
class Rejection {
constructor(reason) {
this.reason = reason
}
}
export default function using(...args) {
const lastIdx = args.length - 1
const fn = args[lastIdx]
if (typeof fn !== 'function') {
throw new Error('Missing expected function argument')
}
if (lastIdx === 0) {
throw new Error('At least two arguments must be passed')
}
const wrapped = Array(lastIdx)
for (let i = 0; i < lastIdx; i++) {
wrapped[i] = args[i].catch((err) => new Rejection(err))
}
return Promise.all(wrapped).then((results) => {
const err = results.find((r) => r instanceof Rejection)
if (err !== undefined) {
return Promise.reject(err.reason)
} else {
return fn(...results)
}
})
}
@addic
Copy link

addic commented Jul 15, 2021

if (lastIdx === 0) { shouldn't this be lastIdx < 2 (0,1 args)?

You are right.

However, it fails for me with a UnhandledPromiseRejection:

[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "Me last!".]

Test case:

const lateReject = new Promise((_, reject) =>
	setTimeout(() => reject('Me first!'), 2000)
)
fancyJoin(
	'Not a Promise haha',
	lateReject,
	Promise.resolve(),
	Promise.reject('Me last!'),
	() => console.log('done')
).catch((err) => {
	console.log('CAUGHT:', err)
})

@tjconcept
Copy link
Author

That's odd, isn't it?

@addic
Copy link

addic commented Jul 15, 2021

That's odd, isn't it?

You are right, it works when executed in shell.

@tjconcept
Copy link
Author

Ah, I think, I get it. It's probably because .then(() => args[i]) never runs in case an early promise rejects..

@addic
Copy link

addic commented Jul 15, 2021

Ah, I think, I get it. It's probably because .then(() => args[i]) never runs in case an early promise rejects..

And so, I think in such case the later rejects would be runaways?

@tjconcept
Copy link
Author

I guess it is also kind of a problem.. You would be silencing any potential error from latter promises.

@addic
Copy link

addic commented Jul 15, 2021

I guess it is also kind of a problem.. You would be silencing any potential error from latter promises.

Hehe, not an issue with my beast :D

@tjconcept
Copy link
Author

I fixed it, I think.

The only deficiency, that could be fixed, would be to "reject earlier". That is, if a value rejects, and all values to the left of it has resolved, reject straight away.
However, if the primary use case is servers, the "common case" would be for all values to resolve, and then this is as optimal as it gets. I think it would be way more complicated to "reject early" too.

Just like the original join (from Bluebird) and the native Promise.all, there's the trade-off that you must accept silencing all other rejections. Only in super edge cases is that an issue, but imagine a bug that occurs in your second parameter only when the first one also rejects, e.g. a syntax error. You'd never find it and it could lead to a memory leak or data corruption:

const a = Promise.reject(new Error('Oops!'))
const b = a.then(
	() => 'Hurra!',
	(err) => FallbackPlan()
)
join(a, b, console.log)

You'll never see that FallbackPlan is not defined, or worse if it fails half-way and doesn't clean up and leaks.

So, if you're the pedantic type, you'd probably not use join or Promise.all if exceptions are part of your flow control for operational loads. All code can do that, but it may be too cumbersome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment