2

I'm implementing a groupBy function and it basically goes like this:

export const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
  objArr: T[],
  property: U,
): { [key in T[U]]: T[] } => objArr
    .reduce((memo, x) => {
      const value = x[property];
      if (!memo[value]) {
        memo[value] = [];
      }
      memo[value].push(x);
      return memo;
    }, {} as { [key in T[U]]: Array<T> });

I know that the typings are off, I've messed around with it too much:

const data = [
   { name: 'corn cob', value: 17, group: 'item' },
   { name: 'Dirty toilet', value: 6, group: 'item' },
   { name: 'snake', value: 2, group: 'animal' },
   { name: 'tesla', value: 17, group: 'car' },
   { name: 'gurgel', value: 23, group: 'car' },
  ];

const result = groupBy(data, 'group')

{
  item: [
    { name: 'corn cob', value: 17, group: 'item' },
    { name: 'Dirty toilet', value: 6, group: 'item' }
  ],
  animal: [ { name: 'snake', value: 2, group: 'animal' } ],
  car: [
    { name: 'tesla', value: 17, group: 'car' },
    { name: 'gurgel', value: 23, group: 'car' }
  ]
}

This is the maximum type safe I could find for this and I still get some errors on the T[U].

Type 'T[U]' is not assignable to type 'string | number | symbol'.

And the return type is as follows:

const result: {
    [x: string]: {
        name: string;
        value: number;
        group: string;
    }[];
}

Is there a way I could achieve this kind of return type:

const result: {
    item: {
        name: string;
        value: number;
        group: string;
    }[];
    animal: {
        name: string;
        value: number;
        group: string;
    }[];
    car: {
        name: string;
        value: number;
        group: string;
    }[];
}

If I could just extract all the input object values of a given key as literals like:

const test = [
  { name: 'corn cob', value: 17, group: 'item' },
  { name: 'Dirty toilet', value: 6, group: 'item' },
  { name: 'snake', value: 2, group: 'animal' },
  { name: 'tesla', value: 17, group: 'car' },
  { name: 'gurgel', value: 23, group: 'car' },
] as const ;


type ValueAtKey = (typeof test)[number]['group']; // "item" | "animal" | "car"

But how can I do this const asertion inside a generic function?

Doing something like this:

type SomeMagicType<T extends Record<string, unknown>[], U extends keyof T[number]> = T[number][U];

export const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
  objArr: T[],
  property: U,
): { [key in SomeMagicType<T, U>]: T[] } => objArr
    .reduce((memo, x) => {
      const value = x[property];
      if (!memo[value]) {
        memo[value] = [];
      }
      memo[value].push(x);
      return memo;
    }, {} as { [key in SomeMagicType<T, U>]: Array<T> });

If not possible, then how can I remove the abover error. I dont get why Type 'T[U]' is not assignable to type 'string | number | symbol' if T extends Record<string, unknown>.

TSPlayground

Omar Omeiri
  • 1,506
  • 1
  • 17
  • 33
  • 1
    The compiler has no idea that `T[U]` will be a key-like thing because you haven't constrained it and you can call `groupBy([{a: null}], "a")` without error, where `T[U]` will be `null`. If you want that constraint you can do [this](https://tsplay.dev/w2EK4W) instead. Note that you still have to do something like `as const` in your declaration of `data` or the compiler will forget all the string literal values of the `group` property. But if you do one big `as const` you get a pretty ugly union type coming out because now the compiler remembers *all* literals. It works though. – jcalz Nov 09 '21 at 20:58
  • 1
    If you want that turned into an answer I can write one up; otherwise, please elaborate on any unmet use case. I might be able to force the output to look more like `Array>` but it will be a complicated implementation, like [this](https://tsplay.dev/mbGdBW); do you care very much? Let me know which if any of those you want as an answer. – jcalz Nov 09 '21 at 21:00
  • @jcalz The second implementation is pretty much on spot! I was trying to avoid the `const` assertion because I'm a little afraid some people will utilize the function incorrectly. But the best I can do is explicitly ask for it in the docs. Thank you very much for your time. I'll accept it as soon as you post it. – Omar Omeiri Nov 09 '21 at 22:01

1 Answers1

3

First of all, if you want the return type of groupBy to have specific keys like item, animal, and car, you will pretty much need to do something like a const assertion when you initialize data. Generally speaking, the compiler infers the type of a string-valued property to be string and not the specific string literal type. So the type of x in const x = {a: "b"} will be inferred to be {a: string} and not {a: "b"}. Since "item", "animal", and "car" are string-valued properties of data, you will need to do some work to preserve their values. The easiest thing to do is this:

const data = [
  { name: 'corn cob', value: 17, group: 'item' },
  { name: 'Dirty toilet', value: 6, group: 'item' },
  { name: 'snake', value: 2, group: 'animal' },
  { name: 'tesla', value: 17, group: 'car' },
  { name: 'gurgel', value: 23, group: 'car' },
] as const;

although now the compiler remembers quite a whole lot about the structure of data:

/* const data: readonly [{
    readonly name: "corn cob";
    readonly value: 17;
    readonly group: "item";
}, {
    readonly name: "Dirty toilet";
    readonly value: 6;
    readonly group: "item";
}, {
    readonly name: "snake";
    readonly value: 2;
    readonly group: "animal";
}, {
    readonly name: "tesla";
    readonly value: 17;
    readonly group: "car";
}, {
    readonly name: "gurgel";
    readonly value: 23;
    readonly group: "car";
}] */

And you only really care about the literal type of group. You could do something more elaborate like

const data = [
  { name: 'corn cob', value: 17, group: 'item' as const },
  { name: 'Dirty toilet', value: 6, group: 'item' as const },
  { name: 'snake', value: 2, group: 'animal' as const },
  { name: 'tesla', value: 17, group: 'car' as const },
  { name: 'gurgel', value: 23, group: 'car' as const },
];

which results in

/* const data: ({
    name: string;
    value: number;
    group: "item";
} | {
    name: string;
    value: number;
    group: "animal";
} | {
    name: string;
    value: number;
    group: "car";
})[] */

which is less ugly I guess. It's up to you.


Anyway, once you have a strongly-typed enough data, we can give groupBy a call signature that uses it properly. Let's start:

declare const groupBy:
  <T extends Record<string, PropertyKey>, K extends keyof T>(
    objArr: readonly T[],
    property: K,
  ) => Record<T[K], T[]>

This is similar to your version (aside from readonly T[] which is more permissive than T[], and a rename from U to the more conventional K for "key"). The important difference here is that I've changed the value type of T from unknown to PropertyKey a standard library defined type equivalent to string | number | symbol, the types usable as keys. So now the compiler knows that T[K] is itself keylike, and can be used as such in Record<T[K], T[]>.

Of course we don't really want to say that every property in T needs to be keylike; we only want to constraint the properties at the key K to that type. So we can rewrite the call signature:

declare const groupBy:
  <T extends Record<K, PropertyKey>, K extends keyof T>(
    objArr: readonly T[],
    property: K,
  ) => Record<T[K], T[]>

Now T depends on K. We could relax the constraint that K extends keyof T to just K extends PropertyKey, since the constraint on T now guarantees that K is a key. But that's up to you.


Anyway, now this will work, but that huge const-asserted type will really come back at us when we look at the type of the result:

const resultOrig = groupBy(data, 'group');
/* const resultOrig: Record<"item" | "animal" | "car", ({
    readonly name: "corn cob";
    readonly value: 17;
    readonly group: "item";
} | {
    readonly name: "Dirty toilet";
    readonly value: 6;
    readonly group: "item";
} | {
    readonly name: "snake";
    readonly value: 2;
    readonly group: "animal";
} | {
    readonly name: "tesla";
    readonly value: 17;
    readonly group: "car";
} | {
    readonly name: "gurgel";
    readonly value: 23;
    readonly group: "car";
})[]> */

That type is correct, but you mentioned you wanted something less specific for the property values. You can make the compiler calculate such a type but it's not simple:

type WidenLiteral<T> = 
  T extends string ? string : 
  T extends number ? number : 
  T extends boolean ? boolean : 
  T;

declare const groupBy: <T extends Record<K, PropertyKey>, K extends PropertyKey>(
   objArr: readonly T[], property: K) => Record<T[K], { 
     [P in PropertyKey & keyof T]: WidenLiteral<T[P]>; 
 }[]> */

What I've done there is written a type WidenLiteral<T> that will convert a type to a widened version where any string, number, or boolean literal types are widened to string, number, and boolean respectively. So WidenLiteral<"a" | Date> will become string | Date, and WidenLiteral<1 | 2 | 3 | 4> will become number.

Then, instead of returning an array of T[], I return an array {[P in PropertyKey & keyof T]: WidenLiteral<T[P]>}. What this does is take the T type, and if it's a union, it collapses it into a single type (and forgets about any properties absent from any one of the members of the union), and then it widens the literal properties with WidenLiteral. So if the input is {a: 0, b: "a", c: true} | {a: 1, b: "b", d: false}, the output is {a: number, b: string}.

Note that this happens automatically when the mapped type over T is not homomorphic, so {[P in keyof T]: 0} is homomorphic and would turn {a: 1, c: 1} | {a: 2, d: 2} into {a: 0, c: 0} | {a: 0, d: 0} but {[P in (PropertyKey & keyof T)]: 0} is not homomorphic and would turn {a: 1, c: 1} | {a: 2, d: 2} into {a: 0}.

Anyway this call signature will turn the mess of data's type into something more pleasant:

const result = groupBy(data, 'group');

/* const result: Record<"item" | "animal" | "car", {
    group: string;
    name: string;
    value: number;
}[]> */

Great!


Oh, but then of course the implementation of groupBy will need at least one type assertion:

const groupBy = <T extends Record<K, PropertyKey>, K extends PropertyKey>(
  objArr: readonly T[],
  property: K,
) => objArr
  .reduce((memo, x) => {
    const value = x[property];
    if (!memo[value]) {
      memo[value] = [];
    }
    memo[value].push(x as any); // <-- here
    return memo;
  }, {} as Record<T[K], { [P in (PropertyKey & keyof T)]: WidenLiteral<T[P]> }[]>);

A type assertion is needed there because while x is obviously a value of type T, it's not so obviously a value of type {[P in ...]: WidenLiteral<...>}. Well, not to the compiler anyway. The compiler isn't good at that type of higher order reasoning about as-yet unspecified generic types like the T inside the body of groupBy.

So this is a little less type safe from the implementer's side, and you need to make sure you wrote it correctly. The compiler can't really catch all mistakes with type assertions. So push(x as any) and push(123 as any) look the same to the compiler.

But from the caller's side, this should hopefully work well (as long as the caller is using specific types and not unspecified generics).

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360