Developerium
Developerium

Reputation: 7265

Typescript: Dynamic object with keys from values of an array

I have made a class to handle promises in batches and return the result based on the key they were given in. For example if you give it two keys named order and customer, each with a promise, it will resolve those promises and return an object with the those keys as the properties and the resolved values as their respective values.

So here is how this class might be used:

const batchPromiseHandler = new BatchPromise();

// getCustomerInfo and getPaymentInfo will give back a promise which resolves into their data

batchPromiseHandler.add('order', getOrderInfo());
batchPromiseHandler.add('customer', getCustomerInfo());

// await to resolve all into result object
const result = await batchPromiseHandler.resolveAll();

console.log(result.order);  // <<-- I want to be able to get suggestion order or customer from IDE
console.log(result.customer); 

And here is the actual implementation:

type resultDecorator = (data: any[], index: number) => any;

class BatchPromise {
  private promiseList: Promise<any>[] = [];
  private keyList: string[] = [];
  private decoratorList: resultDecorator[] = [];

  add(key: string, promise: Promise<any>, decorator?: resultDecorator): void {
    if (this.keyList.indexOf(key) !== -1) {
      throw new Error(`Key: "${key}" already exists in PromiseLand!`);
    }

    this.promiseList.push(promise);
    this.keyList.push(key);
    this.decoratorList.push(decorator);
  }

  async resolveAll(): Promise<{ [key: string]: any }> {   //    <<------ here is naive return type
    const resolvedArray = await Promise.all(this.promiseList);
    const result = {};

    for (let index = 0; index < this.promiseList.length; index++) {
      const key = this.keyList[index];

      result[key] =
        typeof this.decoratorList[index] === 'function'
          ? await this.decoratorList[index](resolvedArray[index], index)
          : resolvedArray[index];
    }

    return result;
  }
}

It works fine as expected but I want to be able to get autocomplete for the result from resolveAll function. I don't know how to use dynamic type features of the language, so I just did this:

Promise<{ [key: string]: any }>

How can I refactor it to be able to get for example order or customer suggested to me by the IDE?

Upvotes: 1

Views: 460

Answers (1)

jcalz
jcalz

Reputation: 327744

The problem here is that the type BatchPromise doesn't know anything about the particular keys and values that it is holding onto. If you want it to keep track of this, it needs to be a generic type like BatchPromise<T>, where T is the object type representing the key-to-value mapping returned in resolveAll().

class BatchPromise<T extends object = {}> { 
  ... 
  async resolveAll(): Promise<T> { ... }
}

So every time you call add(), you'd be changing from a BatchPromise<T> to a BatchPromise<T & Record<K, V>> where K and V are your key and value types, respectively. This hits a bit of a snag: the type system doesn't support arbitrarily changing the type of an existing object. If you're careful, you could write BatchPromise so that add() is seen as narrowing the type, which is supported; this would need to use an assertion function (so that add returns asserts this is BatchPromise<T & Record<K, V>>). But assertion functions are not very easy to use right now, (see microsoft/TypeScript#33622), so I'm not going to provide such a solution.

Instead of making the bp.add() method change the type of bp, things become a lot nicer in the type system if bp.add() returns a BatchPromise object of the modified type:

  add<K extends string, V>(
    key: K, promise: Promise<V>, decorator?: resultDecorator
  ): BatchPromise<T & Record<K, V>> { 
    ... 
    return this as BatchPromise<T & Record<K, V>>;
  }

In order for that to work, you'd want to change how you call add() to incorporate method chaining instead of multiple statements:

const batchPromiseHandler = new BatchPromise()
  .add('order', getOrderInfo())
  .add('customer', getCustomerInfo());

That way your batchPromiseHandler would be of a type like BatchPromise<{order: OrderInfo, customer: CustomerInfo}>.


Let's see if that works:

const result = await batchPromiseHandler.resolveAll();
result.customer; // CustomerInfo
result.order; // OrderInfo
result.randomThing; // error!
// Property 'randomThing' does not exist on type 
// 'Record<"order", OrderInfo> & Record<"customer", CustomerInfo>'

Looks good. And you can verify (via the Playground link below) that an IDE's IntelliSense will be able to prompt that result has a customer and an order property.


Okay, hope that gives you a way forward. Good luck!

Playground link to code

Upvotes: 2

Related Questions