A Better Promise.all() — Utility Types and Functions

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 PromiseSettledResult types 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:

  1. Compile-time validation: TypeScript ensures you’re handling the correct promise types
  2. IntelliSense support: Full autocomplete for resolved structures
  3. Refactoring safety: Changes to promise structures are caught at compile time
  4. Documentation: Types serve as living documentation of your data flow

Performance Considerations

  • deepRecordAll processes promises sequentially within objects, which may not be optimal for large datasets
  • deepRecordAllSettled provides 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 PromiseSettledResult support
  • 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.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *