Home > Mobile >  TypeScript - Dynamically create object interface based on function parameters
TypeScript - Dynamically create object interface based on function parameters

Time:04-08

I'm working on a small project that requires me to create lists of products more or less like so:

let products = createList(
   new Product('product1'),
   new Product('product2')
)

At some point I need to access some product - let's say it's the product1. I'd do it like so - products.list['product1'] and then do some stuff with it. Each product in the list has a name property that's derived from the first parameter in the constructor. At this moment products.list is using a basic string index signature and what I'd like to acheve is so that intellisense could suggest that the list object has properties product1, product2 and so on. Similarly to how request parameters in express are done:

enter image description here

I inspected express' type declarations, read the TS docs on utility types, especially on the Record type as it seemed it was what I've been looking for but couldn't figure out what to do after all.

It's not impossible that I just searched for the wrong stuff. I'd really appreciate if somebody pointed me to the right docs.

CodePudding user response:

In order for this to possibly work, the Product class needs to be generic in the string literal type of the name property. Otherwise, the compiler will only know that the name property is string, and any hope that the keys of createList()'s output would be strongly typed would be gone. So let's say Product looks like this:

class Product<K extends string> {
    constructor(public name: K) { }
    size = 123;
    cost = 456;
}

(Caveat: this definition is quite simple, and Product<K> is currently covariant in K. So a union of Product types will be assignable to a single Product type; e.g., Product<"product1"> | Product<"product2"> will be assignable to Product<"product1" | "product2">. The code below relies on this assignment succeeding. But if Product<K> were complex enough to be invariant in K, this assignment would fail. This could be worked around by changing the code, but I won't digress further here worrying about it.)

Now we can define createList():

function createList<K extends string>(...products: Product<K>[]) {
    return products.reduce(
      (a, p) => ({ ...a, [p.name]: p }), 
      {} as { [P in K]: Product<P> }
    );
}

The function accepts a variadic number of arguments of type Product<K> for some K, which will turn out to be the union of all the name properties of the individual Products. We use products array's reduce() method to build up the desired object, adding a property with key p.name with a value of p for each product p in products.

The return type of createList() is {[P in K]: Product<P>}, a mapped type which has one property P for every member in the K union. The property with key P will be of type Product<P>. The compiler is not smart enough to understand or infer that reduce() actually produces a value of this type, so we use a type assertion to just pretend that the empty initial value passed is of that type.

Let's see if it works:

let products = createList(
    new Product('product1'),
    new Product('product2')
)

/*let products: {
    product1: Product<"product1">;
    product2: Product<"product2">;
} */

Looks good!

Playground link to code

  • Related