I feel like I’m hitting a fundamental limitation of functional programming but wondered if anyone could prove me wrong:
describing this concisely is difficult, so bear with me
I have a map of promises, e.g.:
const namedPromises = {
database: promise1 as Promise<HealthInfo>,
someApi: promise2 as Promise<HealthInfo>,
// ...
} as Record<string, Promise<HealthInfo>>;
The use case for this is health reporting for a service (think calling a /health endpoint and it calls other endpoints/dependencies for their health)
And I want to convert it into a Promise that returns a map of settled promises like so:
function async someMethod<T>(namedPromises: Record<string, Promise<T>>): Promise<Record<string, PromiseSettledResult<T>>> {
// ...magic...
// i know the result will be more generic but this is for example's sake.
return {
database: promiseSettled1 as PromiseSettledResult<T>,
someApi: promiseSettled2 as PromiseSettledResult<T>,
};
}
I am aware of the function Promise.allSettled that takes an array of Promises and returns an array of settled ones, however in order to wrap each named entry of the namedPromises record into a promise, i must call .then and map the result like this:
i provide a key with both resolved and rejected results to know which promise it refers to.
const wrapped = Object.entries(namedPromises).map(([key, promise]) => {
return promise.then(
(value) => ({
key,
result: {
status: 'fulfilled',
value,
} as PromiseFulfilledResult<HealthInfo>,
}),
(reason) => ({
key,
result: {
status: 'rejected',
reason,
},
}),
);
});
Here-in lies the main problem. This meta result is also a Promise, implying it can also reject even though it clearly doesn’t (apart from timeouts which we can handle with a Promise.race([promise, timeoutPromise]). see full example below). The type system doesn’t know any better. Rejected promises don’t know what key they relates to, as that ‘key‘ information is hidden within a successfully resolved promise.
I feel this is a fundamental limitation of functional programming somehow – an instrinsic logistical limitation around named promises that could fail – but i can’t quite justify that feeling with proof.
I find my solution to this to be rather clunky, I filter the top level (wrapped) settled-promise list by fulfilled results only, then return the inner map. Below is an example of my code with this workaround, My question is whether typescript affords a more elegant means of accomplishing this task?
Here is my current solution in full, including timeouts:
async allSettledDependencies(
dependencyHealthReporters: Record<string, Promise<HealthInfo>>,
timeoutPerDependency: number = 1000, // ms
): Promise<Record<string, PromiseSettledResult<HealthInfo>>> {
const withTimeout = (
key: string,
promise: Promise<HealthInfo>,
): Promise<{ key: string; result: PromiseSettledResult<HealthInfo> }> => {
const timeoutPromise = new Promise<HealthInfo>((_, reject) =>
setTimeout(
() =>
reject(new Error(`Timeout exceeded: ${timeoutPerDependency}ms`)),
timeoutPerDependency,
),
);
// allow dependencies to timeout individually to increase resilience of health function.
return Promise.race([promise, timeoutPromise]).then(
(value) => ({
key,
result: {
status: 'fulfilled',
value,
} as PromiseFulfilledResult<HealthInfo>,
}),
// this will be either the timeout rejection, or the rejected promise.
(reason) => ({
key,
result: {
status: 'rejected',
reason,
},
}),
) as Promise<{ key: string; result: PromiseSettledResult<HealthInfo> }>;
};
const settledEntries = await Promise.allSettled(
Object.entries(dependencyHealthReporters).map(
([dependencyName, promise]) => withTimeout(dependencyName, promise),
),
);
return Object.fromEntries(
settledEntries
// we rely on all promises being fulfilled at the `withTimeout` function for this logic to work.
.filter((topSettled) => topSettled.status === 'fulfilled')
.map(({ value }) => [
value.key,
// either returns the resolved health info or converts the rejection error into an
this.healthInfoFromSettledResult(value.result),
]),
);
}
Some related thoughts:
- Is there a way of saying “Trust me bro,These all resolve?” other than
as unknown as PromiseFulfilledResult<...> for the wrapped results?`
- I could call
Promise.all instead of Promise.allSettled if I’m confident about the garunteed settlement of the wrapped promises, something feels smelly about this though.