Alamin Shaikh
Alamin Shaikh

Reputation: 49

How do I declare the return types based on function arguments in typescript

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions