A generic retry-with-backoff wrapper that preserves the wrapped function's signature
Implement withRetry, which wraps any async function and retries it with exponential backoff.
Requirements: the wrapper's parameters and resolved type must be exactly the wrapped function's — calling the wrapper must accept the same arguments and infer the same result. Try up to attempts times, sleeping baseMs * 2 ** (i - 1) before retry number i, and rethrow the last error if every attempt fails. No any, no as assertions.
function withRetry<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
attempts: number,
baseMs: number,
): (...args: A) => Promise<R> {
// your code here
}
Write the implementation.
Make the wrapper generic over the parameter tuple and the result: withRetry<A extends unknown[], R>(fn: (...args: A) => Promise<R>, …): (...args: A) => Promise<R>. Inference fills A and R from the call site, so the wrapper keeps the exact signature with no any and no assertions. The body loops attempts times, awaits the call inside try, sleeps baseMs * 2 ** (i - 1) on failure, and rethrows the last error.
- ✗Falling back to
any[]/Functioninstead of a generic parameter tupleA extends unknown[] - ✗Swallowing the final failure — returning
undefinedrather than rethrowing the last error - ✗Sleeping before the first attempt, so the very first call is needlessly delayed
- →How would you add a
shouldRetry(err: unknown): booleanpredicate without loosening the types? - →Why is
A extends unknown[]a safer constraint here thanA extends any[]?
The solution
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function withRetry<A extends unknown[], R>(
fn: (...args: A) => Promise<R>,
attempts: number,
baseMs: number,
): (...args: A) => Promise<R> {
return async (...args: A): Promise<R> => {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
if (i > 0) await sleep(baseMs * 2 ** (i - 1)); // no delay before the first attempt
try {
return await fn(...args);
} catch (err) {
lastError = err;
}
}
throw lastError; // every attempt failed — surface the last error
};
}
Why the signature survives
Two type parameters carry the whole shape of the function:
A extends unknown[]— the parameter tuple. TypeScript infers it fromfnas a real tuple, so the wrapper's(...args: A)accepts exactly the arguments the original does.R— the resolved type. The wrapper returnsPromise<R>, notPromise<unknown>.
declare function fetchUser(id: string, force: boolean): Promise<{ name: string }>;
const safeFetchUser = withRetry(fetchUser, 3, 100);
// ^? (id: string, force: boolean) => Promise<{ name: string }>
await safeFetchUser("u1", true); // ✅
await safeFetchUser(1); // ❌ type error — just like the original
⚠️ A extends any[] also compiles, but any in the constraint lets anything be passed into fn(...args) if someone later edits the body. unknown[] gives the same tuple inference without opening that hole.
⚠️ The tempting shorter form withRetry<F extends (...a: never[]) => Promise<unknown>>(fn: F): F cannot actually return a value of type F without an assertion — a freshly created arrow function is not the same F. Decomposing into A and R is precisely how you avoid the as.