TypeScript Promises
You're debugging a function that fetches user data from an API. Everything compiles fine, but at runtime, you're getting unhandled promise rejections that crash your app. Or worse, your promises are resolving in the wrong order, and you can't figure out why. These are the kinds of issues that make working with asynchronous code frustrating.
Promises give you a structured way to handle async operations in TypeScript, and when combined with proper typing, they help catch these problems before they reach production. In this guide, we'll walk through creating promises, handling errors effectively, managing concurrent operations, and avoiding common pitfalls that trip up even experienced developers.
Creating a Promise in TypeScript
You create a promise using the Promise constructor, which takes a function with resolve and reject parameters. Here's how it works with a realistic example:
function fetchUserProfile(userId: string): Promise<string> {
return new Promise((resolve, reject) => {
// Simulate an API call
setTimeout(() => {
if (userId) {
resolve(`Profile data for user ${userId}`);
} else {
reject(new Error("User ID is required"));
}
}, 2000);
});
}
fetchUserProfile("user123").then((profile) => {
console.log(profile); // Outputs: Profile data for user user123
});
Modern TypeScript applications, like those using Convex's BaseConvexClient, rely heavily on properly typed promises for handling asynchronous operations. The key is specifying what type your promise will resolve to, which gives you type safety throughout your async code.
Handling Asynchronous Operations with Promises
You can chain promises using the then() method to manage sequential operations. This is useful when each step depends on the previous result. Here's a realistic example of fetching user data and then their posts:
function fetchUser(userId: string): Promise<{ id: string; name: string }> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: userId, name: "Alice" });
}, 1000);
});
}
function fetchUserPosts(userId: string): Promise<string[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(["Post 1", "Post 2", "Post 3"]);
}, 1000);
});
}
fetchUser("user123")
.then((user) => {
console.log(`Found user: ${user.name}`);
// Return the next promise in the chain
return fetchUserPosts(user.id);
})
.then((posts) => {
console.log(`User has ${posts.length} posts`);
});
When building applications with frameworks like Convex, you can use promise chains to manage data flow as demonstrated in their Functional Relationships Helpers guide. Building robust asynchronous workflows often requires careful error handling with TypeScript try catch alongside promise chains.
Using Async/Await with Promises
The async/await syntax simplifies working with promises by letting you write asynchronous code that looks synchronous. Instead of chaining .then() calls, you can use await to pause execution until a promise resolves.
async function getUserData(userId: string) {
try {
// Wait for user to be fetched
const user = await fetchUser(userId);
console.log(`Found user: ${user.name}`);
// Wait for posts to be fetched
const posts = await fetchUserPosts(user.id);
console.log(`User has ${posts.length} posts`);
return { user, posts };
} catch (error) {
// Handle any errors from either promise
console.error("Failed to fetch user data:", error);
throw error;
}
}
getUserData("user123");
This is much cleaner than the equivalent .then() chain. The try/catch block handles errors from any point in the async flow, making error handling more straightforward. When using async/await, proper error handling with TypeScript try catch becomes essential for managing rejected promises.
Async functions always return a promise, making them seamlessly compatible with Convex's asynchronous data patterns described in their Code Spelunking API Guide.
Running Multiple Promises Concurrently
Sometimes you need to run multiple async operations at the same time rather than sequentially. TypeScript provides several methods for handling concurrent promises, each with different behavior.
Promise.all() - All or Nothing
Promise.all() runs multiple promises in parallel and waits for all of them to succeed. If any promise rejects, the entire operation fails immediately.
async function loadDashboardData() {
try {
// Run all three fetches simultaneously
const [user, posts, comments] = await Promise.all([
fetchUser("user123"),
fetchUserPosts("user123"),
fetchUserComments("user123"),
]);
console.log("All data loaded:", { user, posts, comments });
} catch (error) {
// If ANY request fails, we end up here
console.error("Dashboard loading failed:", error);
}
}
This is perfect when you need all the data to proceed. If you're fetching user data, settings, and permissions, and you can't show the dashboard without all three, use Promise.all().
Promise.allSettled() - Handle Partial Failures
What if you want results from all promises, even if some fail? Promise.allSettled() waits for all promises to finish, regardless of success or failure.
async function loadOptionalData() {
const results = await Promise.allSettled([
fetchUser("user123"),
fetchUserPosts("user123"),
fetchUserComments("user123"),
]);
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`Request ${index} succeeded:`, result.value);
} else {
console.log(`Request ${index} failed:`, result.reason);
}
});
}
This is useful when you're loading optional features. If fetching recent posts fails, you can still show the user profile with empty posts.
Promise.race() - First One Wins
Promise.race() resolves or rejects as soon as the first promise settles, ignoring the rest.
async function fetchWithTimeout(url: string, timeoutMs: number) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Request timed out")), timeoutMs);
});
try {
// Race the fetch against the timeout
const response = await Promise.race([fetch(url), timeoutPromise]);
return response;
} catch (error) {
console.error("Fetch failed or timed out:", error);
throw error;
}
}
This pattern is great for implementing timeouts or fallback strategies when you have multiple data sources.
When managing complex data relationships in Convex applications, promise combinators are essential as shown in their Functional Relationships Helpers guide. For more advanced type safety in promise chains, consider using TypeScript generics to specify return types at each step.
Handling Errors in Promises
Unhandled promise rejections can crash your application or leave it in an inconsistent state. You need to handle errors at every level of your async code.
Using .catch() with Promise Chains
The .catch() method catches any error that occurs in the promise chain:
fetchUser("user123")
.then((user) => {
console.log(`Found user: ${user.name}`);
return fetchUserPosts(user.id);
})
.then((posts) => {
console.log(`User has ${posts.length} posts`);
})
.catch((error) => {
// Catches errors from ANY promise in the chain
console.error("Something went wrong:", error);
});
Using try/catch with Async/Await
With async/await, you use standard try/catch blocks, which feel more natural if you're coming from synchronous code:
async function getUserData(userId: string) {
try {
const user = await fetchUser(userId);
const posts = await fetchUserPosts(user.id);
return { user, posts };
} catch (error) {
console.error("Failed to fetch user data:", error);
// You can re-throw, return a default value, or handle it here
return null;
}
}
Effective error handling in TypeScript promises requires understanding the TypeScript try catch pattern. The catch() method in promise chains serves a similar purpose to try/catch blocks in synchronous code.
Typing Promises for Specific Return Values
TypeScript lets you specify what type a promise will resolve to, which gives you autocomplete and catches type errors at compile time.
interface UserProfile {
id: string;
name: string;
email: string;
}
// Explicitly type the promise return value
function fetchUserProfile(userId: string): Promise<UserProfile> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
id: userId,
name: "Alice Johnson",
email: "alice@example.com",
});
}, 1000);
});
}
// TypeScript knows 'user' is a UserProfile
fetchUserProfile("user123").then((user) => {
console.log(user.name); // Type-safe access
console.log(user.invalidProp); // TypeScript error!
});
When defining promise types, you're essentially using TypeScript generics with the Promise type. The TypeScript interface example above demonstrates how to structure complex data types for promises.
Typing Promise Errors
You can also type your errors for better error handling. This is useful when you want to handle different error types differently:
interface ApiError {
code: string;
message: string;
statusCode: number;
}
async function fetchWithTypedError(url: string): Promise<Response> {
try {
const response = await fetch(url);
if (!response.ok) {
const error: ApiError = {
code: "API_ERROR",
message: `HTTP ${response.status}: ${response.statusText}`,
statusCode: response.status,
};
throw error;
}
return response;
} catch (error) {
// Type guard to check if it's our ApiError
if (
error &&
typeof error === "object" &&
"code" in error &&
"statusCode" in error
) {
const apiError = error as ApiError;
if (apiError.statusCode === 404) {
console.error("Resource not found");
} else if (apiError.statusCode === 500) {
console.error("Server error");
}
}
throw error;
}
}
This pattern gives you type safety for both successful results and errors, making your error handling more robust.
Converting Callback Functions to Promises
You'll often encounter legacy code that uses callbacks instead of promises. Converting them makes your code more maintainable and compatible with modern async patterns.
// Legacy callback-based function
function readFileCallback(
filename: string,
callback: (error: Error | null, data: string | null) => void
) {
setTimeout(() => {
if (filename) {
callback(null, `Contents of ${filename}`);
} else {
callback(new Error("Filename is required"), null);
}
}, 1000);
}
// Convert to promise-based function
function readFilePromise(filename: string): Promise<string> {
return new Promise((resolve, reject) => {
readFileCallback(filename, (error, data) => {
if (error) {
reject(error);
} else {
resolve(data!); // Non-null assertion since we know data exists
}
});
});
}
// Now you can use async/await
async function main() {
try {
const content = await readFilePromise("example.txt");
console.log(content);
} catch (error) {
console.error("Failed to read file:", error);
}
}
This conversion pattern transforms legacy callback-based code into modern promise-based syntax. When working with function parameters in TypeScript, understanding TypeScript types helps ensure proper type definitions. The transformation process becomes more powerful when combined with TypeScript utility types to create flexible, type-safe conversions.
Mistakes to Avoid with TypeScript Promises
Even experienced developers make these mistakes with promises. Here's what to watch out for:
Floating Promises
One of the most common bugs is forgetting to handle a promise. When you call an async function but don't await it or attach error handlers, it's called a "floating promise."
// BAD: Floating promise - errors are silently swallowed
function saveToDatabaseBad() {
writeToDatabase(); // Returns a promise, but we're not handling it
console.log("Save initiated"); // This runs immediately, not after save
}
// GOOD: Properly handling the promise
async function saveToDatabaseGood() {
try {
await writeToDatabase();
console.log("Save completed");
} catch (error) {
console.error("Save failed:", error);
}
}
Enable TypeScript's no-floating-promises ESLint rule to catch these automatically.
Sequential vs Parallel Execution
When you use await in a loop or multiple times in a row, operations run sequentially. This is often slower than necessary.
// BAD: Sequential execution - takes 3 seconds total
async function fetchUsersSlow(ids: string[]) {
const users = [];
for (const id of ids) {
const user = await fetchUser(id); // Each waits for the previous
users.push(user);
}
return users;
}
// GOOD: Parallel execution - takes 1 second total
async function fetchUsersFast(ids: string[]) {
const promises = ids.map((id) => fetchUser(id));
return await Promise.all(promises); // All run simultaneously
}
Use sequential await when operations depend on each other. Use Promise.all() when they're independent.
Not Returning Promises from .then()
When chaining promises, you need to return the next promise if you want the chain to wait for it.
// BAD: The second fetch isn't part of the chain
fetchUser("user123")
.then((user) => {
fetchUserPosts(user.id); // Not returned! Chain doesn't wait
console.log("This runs immediately");
})
.then((posts) => {
console.log(posts); // undefined - posts never arrived
});
// GOOD: Return the promise to keep the chain connected
fetchUser("user123")
.then((user) => {
return fetchUserPosts(user.id); // Returned properly
})
.then((posts) => {
console.log(posts); // Now we have the posts
});
Missing Error Handlers
Every promise should have error handling, either via .catch() or try/catch.
// BAD: No error handling - will crash on rejection
async function riskyOperation() {
const data = await fetchData();
return processData(data);
}
// GOOD: Errors are handled gracefully
async function safeOperation() {
try {
const data = await fetchData();
return processData(data);
} catch (error) {
console.error("Operation failed:", error);
return null; // Or throw, or return a default value
}
}
Final Thoughts about TypeScript Promises
Mastering promises gives you control over async operations that would otherwise lead to callback hell or hard-to-debug race conditions. Here's what to remember:
- Type your promises with
Promise<T>to catch errors at compile time - Use
async/awaitfor cleaner code, but understand when.then()chains are appropriate - Choose the right combinator:
Promise.all()when you need all results,Promise.allSettled()for partial failures,Promise.race()for timeouts - Always handle errors with try/catch or
.catch()to prevent unhandled rejections - Watch out for floating promises and accidental sequential execution
With proper typing and error handling, promises transform async code from a source of bugs into a reliable pattern you can trust in production.