Working with nested promises in TypeScript can be challenging. Here, I’ll introduce a set of utility types and functions that make deep promise handling more ergonomic and type-safe.
The Problem
When working with complex data structures that contain promises at various levels, you often need to:
- Wrap all values in promises (shallow or deep)
- Handle
PromiseSettledResulttypes consistently - Recursively resolve all promises in objects and arrays
- Maintain type safety throughout the process
Traditional approaches require manual type mapping and can lead to verbose, error-prone code.
The Solution: Utility Types
Here are three utility types and examples.
ShallowPromisify
The ShallowPromisify type wraps all values in a Promise, but only one level deep:
export type ShallowPromisify<T> =
T extends Array<infer V>
? Array<Promise<V>>
: T extends object
? { [K in keyof T]: Promise<T[K]> }
: Promise<T>;
This is useful when you want to convert a synchronous data structure into one where all leaf values are promises, but you don’t want to deeply nest promises.
Example:
type User = {
id: number;
name: string;
profile: {
avatar: string;
bio: string;
};
};
type PromisifiedUser = ShallowPromisify<User>;
// Result:
// {
// id: Promise<number>;
// name: Promise<string>;
// profile: Promise<{
// avatar: string;
// bio: string;
// }>;
// }
ShallowSettled
Similar to ShallowPromisify, but wraps values in PromiseSettledResult:
export type ShallowSettled<T> =
T extends Array<infer V>
? Array<PromiseSettledResult<V>>
: T extends object
? { [K in keyof T]: PromiseSettledResult<T[K]> }
: PromiseSettledResult<T>;
This is perfect for scenarios where you want to handle both fulfilled and rejected promises gracefully.
DeepAwaited
Recursively unwraps all promises in a structure:
export type DeepAwaited<T> =
T extends Promise<infer U>
? DeepAwaited<U>
: T extends Array<infer V>
? Array<DeepAwaited<V>>
: T extends object
? { [K in keyof T]: DeepAwaited<T[K]> }
: T;
This type is invaluable when you have deeply nested promises and want to know what the final resolved structure will look like.
Example:
type NestedPromises = {
user: Promise<{
id: Promise<number>;
posts: Promise<Array<Promise<string>>>;
}>;
};
type Resolved = DeepAwaited<NestedPromises>;
// Result:
// {
// user: {
// id: number;
// posts: string[];
// };
// }
DeepSettled
Applies PromiseSettledResult only to promises in a structure, leaving non-promise values unchanged:
export type DeepSettled<T> =
T extends Promise<infer U>
? PromiseSettledResult<DeepSettled<U>>
: T extends Array<infer V>
? Array<DeepSettled<V>>
: T extends object
? { [K in keyof T]: DeepSettled<T[K]> }
: T;
This is particularly useful when you want to preserve the original structure while adding error handling to promise values.
Runtime Functions
We can also use runtime functions.
deepRecordAll
The deepRecordAll function recursively resolves all promises in an object or array:
export async function deepRecordAll<T>(input: T): Promise<DeepAwaited<T>> {
if (input instanceof Promise) {
return deepRecordAll(await input) as any;
}
if (Array.isArray(input)) {
return Promise.all(input.map(deepRecordAll)) as any;
}
if (input && typeof input === "object") {
const entries = Object.entries(input as Record<string, any>);
const resolvedEntries = await Promise.all(
entries.map(async ([k, v]) => [k, await deepRecordAll(v)]),
);
return Object.fromEntries(resolvedEntries) as any;
}
return input as any;
}
Usage Example:
const data = {
user: Promise.resolve({
id: Promise.resolve(123),
posts: Promise.resolve([
Promise.resolve("Post 1"),
Promise.resolve("Post 2")
])
}),
metadata: {
count: 42, // non-promise value
timestamp: Promise.resolve(new Date())
}
};
const resolved = await deepRecordAll(data);
// Result:
// {
// user: {
// id: 123,
// posts: ["Post 1", "Post 2"]
// },
// metadata: {
// count: 42,
// timestamp: Date
// }
// }
deepRecordAllSettled
Similar to deepRecordAll, but uses Promise.allSettled for better error handling:
export async function deepRecordAllSettled<T>(
input: T,
): Promise<DeepSettled<T>> {
if (input instanceof Promise) {
const settled = await Promise.allSettled([input]);
if (settled[0].status === "fulfilled") {
const value = await deepRecordAllSettled(settled[0].value);
return { status: "fulfilled", value } as any;
} else {
return settled[0] as any;
}
}
if (Array.isArray(input)) {
const results = await Promise.all(input.map(deepRecordAllSettled));
return results as any;
}
if (input && typeof input === "object") {
const entries = Object.entries(input as Record<string, any>);
const settledEntries = await Promise.all(
entries.map(async ([k, v]) => [k, await deepRecordAllSettled(v)]),
);
return Object.fromEntries(settledEntries) as any;
}
return input as any;
}
This function is perfect when you want to handle partial failures gracefully without the entire operation failing.
Practical Use Cases
Let’s look at three practical use cases.
API Response Processing
When dealing with complex API responses that contain nested promises:
async function fetchUserData(userId: number) {
const userData = {
profile: fetchUserProfile(userId),
posts: fetchUserPosts(userId),
friends: fetchUserFriends(userId).then(friends =>
friends.map(friend => fetchFriendDetails(friend.id))
)
};
// All promises resolved, maintaining structure
return await deepRecordAll(userData);
}
Batch Operations with Error Handling
When you need to process multiple operations but want to continue even if some fail:
async function processBatch(operations: Array<Promise<any>>) {
const results = await deepRecordAllSettled({
operations,
metadata: {
timestamp: new Date(),
count: operations.length
}
});
// Handle partial failures
const successful = results.operations.filter(r => r.status === "fulfilled");
const failed = results.operations.filter(r => r.status === "rejected");
return { successful, failed, metadata: results.metadata };
}
Configuration Loading
When loading configuration from multiple sources:
async function loadConfig() {
const config = {
database: loadDatabaseConfig(),
api: loadApiConfig(),
features: loadFeatureFlags().then(flags =>
flags.map(flag => validateFeatureFlag(flag))
)
};
return await deepRecordAll(config);
}
Type Safety Benefits
These utilities provide several type safety advantages:
- Compile-time validation: TypeScript ensures you’re handling the correct promise types
- IntelliSense support: Full autocomplete for resolved structures
- Refactoring safety: Changes to promise structures are caught at compile time
- Documentation: Types serve as living documentation of your data flow
Performance Considerations
deepRecordAllprocesses promises sequentially within objects, which may not be optimal for large datasetsdeepRecordAllSettledprovides better error isolation but may be slower due to additional promise wrapping- Consider using these utilities judiciously for performance-critical paths
Utility Types & Functions
These utility types and functions provide a robust foundation for working with complex promise structures in TypeScript. They offer:
- Type safety through compile-time validation
- Error resilience with
PromiseSettledResultsupport - Flexibility in handling both shallow and deep promise structures
- Maintainability through clear, reusable abstractions
By incorporating these utilities into your TypeScript projects, you can write more expressive, type-safe code when dealing with asynchronous operations across complex data structures.