TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
242: Typing a Retry-with-Backoff Utility
I've been dealing with a flaky third-party payment gateway recently. It doesn't always fail permanently; sometimes it just hiccups. The standard solution is a retry mechanism with exponential backoff—where we wait a bit longer after each failure so we don't accidentally DDoS the server we're trying to reach. I want to write a utility for this, but I want it to be completely type-safe. I don't want to cast everything to any and lose the IDE's help when I'm actually calling the API.
The "just make it work" approach
My first instinct is to keep it simple. I'll just take a function, a max number of retries, and a delay. Let's see what happens when I write this out blindly:
async function retryWithBackoff(fn, retries = 3, delay = 1000) {
try {
return await fn();
} catch (error) {
if (retries <= 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return retryWithBackoff(fn, retries - 1, delay * 2);
}
}
TypeScript is already screaming at me. Since I didn't provide types for fn, it's any. More importantly, the return type of retryWithBackoff is Promise<any>. If I'm calling a function that returns a User object, I don't want the result to be any; I want it to be User. That's a non-starter.
Preserving the return value
Okay, let's introduce a generic. I'll tell TypeScript that this function returns whatever the wrapped function returns. I'll use T to represent that result.
async function retryWithBackoff<T>(
fn: () => Promise<T>,
retries = 3,
delay = 1000
): Promise<T> {
try {
return await fn();
} catch (error) {
if (retries <= 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return retryWithBackoff(fn, retries - 1, delay * 2);
}
}
This is better. Now, if I pass a function that returns Promise<string>, the utility returns Promise<string>. But here's the problem: this only works for functions that take zero arguments. In the real world, my API call looks like fetchPayment(orderId, amount). I can't just pass fetchPayment into this utility because fetchPayment requires arguments, but my fn type only allows functions with no parameters.
The argument struggle
I could wrap the call in an anonymous function, like retryWithBackoff(() => fetchPayment(id, amt)), but that's tedious. I'd rather be able to pass the function and its arguments separately. Let's try to make the utility variadic.
I need a way to say: "This utility takes a function, and then it takes whatever arguments that specific function needs." This is where generic parameter arrays come in. I'll add a second generic TArgs that extends an array.
async function retryWithBackoff<T, TArgs extends any[]>(
fn: (...args: TArgs) => Promise<T>,
args: TArgs,
retries = 3,
delay = 1000
): Promise<T> {
try {
return await fn(...args);
} catch (error) {
if (retries <= 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return retryWithBackoff(fn, args, retries - 1, delay * 2);
}
}
Wait, I just noticed something annoying. If I call this, I have to pass the arguments as an array: retryWithBackoff(fetchPayment, [id, amt]). That feels clunky. I want to use the spread operator in the function signature so I can call it like retryWithBackoff(fetchPayment, id, amt).
Refining the signature
To make the arguments feel natural, I have to change how the function is structured. Instead of passing the arguments as a single array parameter, I'll use the rest parameter syntax. But there's a catch: TypeScript struggles to infer TArgs if they are spread across the function call while also trying to infer T from the function passed as the first argument.
Let's try this structure:
async function retryWithBackoff<T, TArgs extends any[]>(
fn: (...args: TArgs) => Promise<T>,
...args: TArgs
): Promise<T> {
// Wait, I lost my 'retries' and 'delay' parameters!
}
Damn. I can't have it both ways with a single function signature because the ...args must come last. If I want the retries and delay to be configurable, I have two choices: either they stay as part of an options object, or I go back to passing the arguments as an array. In a professional library, an options object is almost always the cleaner move.
Here is the final, polished version. I'll move the configuration into an object so the arguments can remain flexible:
interface RetryOptions {
retries?: number;
delay?: number;
}
async function retryWithBackoff<T, TArgs extends any[]>(
fn: (...args: TArgs) => Promise<T>,
options: RetryOptions,
...args: TArgs
): Promise<T> {
const { retries = 3, delay = 1000 } = options;
try {
return await fn(...args);
} catch (error) {
if (retries <= 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
// Note: we pass the updated options object recursively
return retryWithBackoff(fn, { retries: retries - 1, delay: delay * 2 }, ...args);
}
}
Now, when I use this, TypeScript knows exactly what TArgs are based on the function I pass in, and it ensures the ...args I provide at the end match that function's signature perfectly. If I try to pass a string where the API expects a number, the compiler will stop me immediately. That's the level of safety I'm looking for.
📋 Practical Task
Implement a Typed Retry-with-Backoff for a Mock Weather API
You have a mock function fetchWeather(city: string, unit: 'metric' | 'imperial') that randomly throws errors to simulate a flaky network. Your task is to implement the retryWithBackoff utility as discussed in the lesson and use it to successfully fetch the weather.
Requirements:
- The
retryWithBackofffunction must use generics to preserve the return type of the passed function and the type safety of its arguments. - It must accept an options object for
retriesanddelay. - It must implement exponential backoff (doubling the delay each time).
- Demonstrate a successful call to
fetchWeatherusing the utility, ensuring that passing an invalid unit (e.g.,'kelvin') results in a TypeScript compilation error.
// Provided Mock API
async function fetchWeather(city: string, unit: 'metric' | 'imperial') {
if (Math.random() > 0.7) {
return { city, temp: 22, unit };
}
throw new Error("Network Timeout");
}
// Your implementation here...
There are no comments for now.