1

I am trying to add types to function that takes array of objects and groups them by key.

Here is my code:

interface ITask {
  assignedTo: string;
  tasks: Array<string>,
  date: string;
  userId: number;
};

interface IFormattedOutput<T> {
  [prop: string]: Array<T>;
}

const testData: Array<ITask> = [
    {
      assignedTo: 'John Doe',
      tasks:['Code refactoring', 'Demo', 'daily'],
      date: '1-12-2022',
      userId: 123
    },
    {
      assignedTo: 'Maximillian Shwartz',
      tasks:['Onboarding','daily'],
      date: '1-12-2022',
      userId: 222
    },
    {
      assignedTo: 'John Doe',
      tasks:['Team Menthoring', 'daily', 'technical call'],
      date: '1-13-2022',
      userId: 123
    },
    {
      assignedTo: 'John Doe',
      tasks:['New dev onboardin','daily'],
      date: '1-12-2022',
      userId: 123
    }
]

const groupByKey = <T, K extends keyof T>(list: Array<T>, key: K):IFormattedOutput<T> => {
  return list.reduce((reducer, x) => {
    (reducer[x[key]] = reducer[x[key]] || []).push(x);
    return reducer;
  }, {});
};

const res = groupByKey <ITask, 'assignedTo'>(testData, 'assignedTo');

console.log(res);

Unfortunately o am getting TS error 'Type 'T[K]' cannot be used to index type '{}'.'

Is there anything to do to fix this error?

1
  • You need to type the initial value of the reducer, but this function still has a typing issue cause T[K] isnt restricted to being a string Commented Mar 27, 2022 at 15:43

2 Answers 2

2

2 problems:

  1. not typing the reducer seed properly, causing your current error

  2. even if you fix 1, you'd get a similar error, because you haven't restricted K to only point at string values of T, so the compiler will correctly warn you that it doesn't know if type T[K] can index IFormattedOutput<T>

ideal solution here will have the compiler throwing errors at you when you try to use this function improperly, so you need to define a type that only allows keys where T[K] is of type string, can do so like this:

// this maps type T to only the keys where the key has a string value
type StringKeys<T> = { 
    [k in keyof T]: T[k] extends string ? k : never 
}[keyof T];

// this narrows type T to only it's properties that have string values
type OnlyString<T> = { [k in StringKeys<T>]: string };

then type your function like so:

// T extends OnlyString<T> tells compiler that T can be indexed by StringKeys<T>
// then you type key to be StringKeys<T> so compiler knows it must have a string value
// now the key must be a key of T that has a string value, so the index issues are solved
const groupByKey = <T extends OnlyString<T>>(list: T[], key: StringKeys<T>): IFormattedOutput<T> => {
  return list.reduce((reducer, x) => {
    (reducer[x[key]] = reducer[x[key]] || []).push(x);
    return reducer;
  }, {} as IFormattedOutput<T>); // also need to type reducer seed correctly
};

now the wrong input will throw type errors at you.

so these work as expected:

// note you don't need to declare the types, TS can infer them
groupByKey(testData, 'assignedTo');
groupByKey(testData, 'date');

but if you did:

groupByKey(testData, 'tasks');
groupByKey(testData, 'userId');

you'd get compiler errors

playground link

Sign up to request clarification or add additional context in comments.

Comments

0

There are 2 steps, I tested on my local and succeeded.

First, I changed your interface IFormattedOutput<T> to this:

interface IFormattedOutput<T> {
  [key: string]: Array<T>;
}

The groupByKey function will be like as follow:

// type guard for T[K] to be string otherwise throw an error
const isString = (propKey: any): propKey is string => typeof propKey === 'string';

const groupByKey = <T, K extends keyof T>(list: Array<T>, key: K): IFormattedOutput<T> =>
  list.reduce((reducer, x) => {
    const propKey = x[key];

    // this type narrowing is necessary to get rid of the
    // "type T[K] cannot be used to index type IFormattedOutput
    // due to type ambiguity.

    if (isString(propKey)) {
      (reducer[propKey] = reducer[propKey] || []).push(x);
    } else {
      throw new Error(`Expected string, got '${typeof propKey}'.`);
    }
    return reducer;

  }, {} as IFormattedOutput<T>);

I think Type guard and Typescript type narrowing are really important practices and seem to solve a lot of TS issues that I have come across.

Please note that even when this Typescript issue is solved, my eslint still has a warning because you are trying to reassign the reducer, so I silenced it with // eslint-disable-next-line no-param-reassign.

Let me know if this solves your issue.

7 Comments

I think the current interface interface IFormattedOutput<T> is an overdo - i dont think so. i want to keep this function ready to use with any array of objects, not only hardcoded
@Mariik, very well, that's fair. I updated my answer accordingly to your requirement and it still works. The core idea is type narrowing, not the generic type for IFormattedOutput. You can check it out.
This is less than ideal. Preferably you’d type T so that T[K] has to be a string and the TS compiler will throw an error at you if it’s not.
@bryan60 added type guard instead of a normal type check and throw error during run time if T[K] is not a string. Seems to be the best practice also used in the official documentation here
a runtime error is better than nothing, but the ideal here is still to have a type error thrown by the compiler. which is doable
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.