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:
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 Product
s. 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!