Home > Enterprise >  How to type array of unknown objects in TS?
How to type array of unknown objects in TS?

Time:03-28

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?

CodePudding user response:

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

CodePudding user response:

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.

  • Related