Reputation: 49
I use the below function to group items based on a property. E.g I would like to group the cars array by year.
So I pass the year as key, cars array as items and cars as itemsName.
And the function returns an array of objects like
[
{
year: 2012,
cars: [{make: ‘Audi’, model: ‘r8’}, {make: ‘Ford’, model: ‘mustang’}]
}
]
My question is how do I declare types or interfaces for the returned values? Please note that I will use different arrays e.g a restaurants array to group them by scheduled date so the types needs to dynamic.
const cars = [
{
'make': 'audi',
'model': 'r8',
'year': '2012'
}, {
'make': 'audi',
'model': 'rs5',
'year': '2013'
}, {
'make': 'ford',
'model': 'mustang',
'year': '2012'
}, {
'make': 'ford',
'model': 'fusion',
'year': '2015'
}, {
'make': 'kia',
'model': 'optima',
'year': '2012'
},
];
// Group items by property
export function groupBy(key, items, itemsName) {
// Crate groups with provided key
const groupsObj = items.reduce((acc, curr) => {
// Property to create group with
const property = curr[key];
// If property exists in acc then,
// add the current item to the property array
if (property in acc) {
return { ...acc, [property]: [...acc[property], curr] };
}
// Else create a property and
// add the current item to an array
return { ...acc, [property]: [curr] };
}, {});
// Convert the object
const groupsArr = Object.keys(groupsObj).map((property) => ({
[key]: property,
[itemsName]: groupsObj[property],
}));
return groupsArr;
}
Upvotes: 2
Views: 116
Reputation: 328152
My approach would be to give groupBy()
the call signature
function groupBy<T extends Record<K, string>, K extends keyof T, P extends PropertyKey>(
key: K, items: T[], itemsName: P): Grouped<T, K, P>[] { /*...*/ }
where Grouped
is defined as
type Grouped<T extends object, K extends keyof T, P extends PropertyKey> =
{ [Q in K]: T[K] } & { [Q in P]: T[] };
In order for the return type of groupBy()
to depend on its argument types, you pretty much need it to be generic in every type you need to keep track of. Here I've made it generic in three type parameters. There's T
, the element type of the items
array; there's K
, the type of the key
argument, and P
, the type of the itemsName
argument.
It's important to constrain these type parameters to just valid inputs. For example, we want to make sure that K
is one of the keys of T
; hence K extends keyof T
using the keyof
type operator. We also want to make sure that T
has a string
value at the K
property, so you can use it as an index of groupsObj
inside your implementation (I kind of think groupsObj
is a more useful output than an array you have to search through, but that's up to you); hence T extends Record<K, string>
using the Record<K, V>
utility type. And finally we want to make sure that P
is some keylike type; hence P extends PropertyKey
using the PropertyKey
type.
So that takes care of the input... how about the output? The type Grouped<T, K, P>
is intended to be the element type of the returned array. It is equivalent to Record<K, T[K]> & Record<P, T[]>
. That means it has a property with key K
(the same as key
) whose value is of the same type T
has at key K
(represented by the indexed access type T[K]
), and (represented by the intersection &
) it has a property with key P
(the same as itemsName
) whose value is an array of T
(the same type as items
).
Let's make sure we can use it:
interface Car {
make: string;
model: string;
year: string;
}
const cars: Car[] = [/*...*/]
const ret = groupBy("year", cars, "cars");
/* const ret: Grouped<Car, "year", "cars">[] */
const str = ret.map(
v => v.year + ": " + v.cars.map(
//^ ^^^^-- (property) year: string
//|--- (parameter) v: Grouped<Car, "year", "cars">
c => c.make + " " + c.model
// ^ <-- (parameter) c: Car
).join(", ")
).join("\n");
So ret
is of type Grouped<Car, "year", "cars">[]
, which means it's an array of objects with a year
property of type string
, and a cars
property of type Car[]
. So it works well from the caller's side.
As for the implementation, you need to annotate and assert types in some places to remind/persuade/force the compiler to see that it conforms to that call signature. Here's one way to do it:
function groupBy<T extends Record<K, string>, K extends keyof T, P extends PropertyKey>(
key: K, items: T[], itemsName: P): Grouped<T, K, P>[] {
const groupsObj = items.reduce<Record<string, T[]>>((acc, curr) => {
const property: string | number = curr[key];
if (property in acc) {
return { ...acc, [property]: [...acc[property], curr] };
}
return { ...acc, [property]: [curr] };
}, {});
const groupsArr = Object.keys(groupsObj).map((property) => ({
[key]: property,
[itemsName]: groupsObj[property],
}) as Grouped<T, K, P>);
return groupsArr;
}
We have to tell the compiler that the initial object {}
passed to the reduce()
array method is of type Record<string, T[]>
so that it understands that you'll be placing arrays of T
in its property values no matter what the key is.
And we need to just assert that {[key]: property, [itemsName]: groupsObj[property]}
is of type Grouped<T, K, P>
since it doesn't understand how to give strong typings to computed properties whose keys are generic (see this answer to another question for details).
So now that all compiles and hopefully works as you need.
Upvotes: 1