I have a routine for rate-limiting, concurrency, retry and timeout which iterates over a series of async operations.
However, typing for this routine leads to a confusing situation when the return value of fn doesn't have the type of ReturnType<typeof fn>. I would like to know why it's happening and find a workaround.
The issue is present in this minimal consume() example below. It just yields the resolved values and the provided async functions (the functions may have metadata added by the user to identify the values, hence the Generic)...
interface ResolutionEvent<AsyncFn extends () => Promise<unknown>> {
eventType: "resolved";
factory: AsyncFn;
value: Awaited<ReturnType<AsyncFn>>;
}
export async function* consume<AsyncFn extends () => Promise<unknown>>(
factoryIterator: AsyncIterator<AsyncFn>
): AsyncGenerator<ResolutionEvent<AsyncFn>> {
for (;;) {
const iteration = await factoryIterator.next();
if (iteration.done) {
return;
}
const factory: AsyncFn = iteration.value;
// compiler error for `promise` assignment in line below:
// Type 'Promise<unknown>' is not assignable to type 'ReturnType<AsyncFn>'.
const promise: ReturnType<AsyncFn> = factory();
const value: Awaited<ReturnType<AsyncFn>> = await promise;
yield {
eventType: "resolved",
factory,
value,
};
}
}
I would normally rely on inferred typing throughout this routine, but I have explicitly annotated the types of factory, promise etc. for debugging purposes. The only compiler error is the one shown, which demonstrates that from a types point of view factory is definitely an AsyncFn but its return value is not ReturnType<AsyncFn> !
Is there a workaround for this single line issue? The whole of the rest of my implementation holds together well. Crucially it is simple to reason about based on a single type (the type of the async fn).
Restructuring every type and function call to have <T, AsyncFn extends () => T = () => T> makes the whole library much harder to make sense of, and would add nothing.
For reference, the AsyncFn type must be preserved. I can't reduce it to just T as users are expected to add payloads to each AsyncFn according to their needs (e.g. an id or a sequence index) so that the eventual (shuffled) results they get back can be correlated with the operations that created them.
Below is a motivating example of the kind of specialisation of the AsyncFn type which needs to be preserved in the generic ResolutionEvent type. Extra data added to the function means the user can reconcile the async values as they are resolved...
const fn = () => new Promise((resolve) => setTimeout(resolve, timeout));
fn.requestId = requestId++;
yield fn;
I may have no choice but to restructure how a user passes a factory, and how generics are expressed but just using the function type itself would be a really simple and elegant option.