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