Published on
-6 min read

Type Safe GroupBy In TypeScript

Table of Contents

Lodash's groupBy

I would bet if you have a sizeable Javascript/Typescript codebase you most likely are using lodash somewhere in there. While Javascript has gotten more "batteries included" over the last few years, lodash still has many nice functions for manipulating arrays/objects. One such function is groupBy. It groups a list by some predicate, in the simplest case it can just be a key in the objects of the array.

import const _: import("/home/runner/work/JRMurr.github.io/JRMurr.github.io/node_modules/@types/lodash/index.d.ts").LoDashStatic_ from 'lodash';

interface Foo {
  Foo.num: numbernum: number;
  Foo.someLiteral: "a" | "b" | "c"someLiteral: 'a' | 'b' | 'c';
  Foo.object: Record<string, any>object: type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, any>;
} const const vals: Foo[]vals: Foo[] = [ { Foo.num: numbernum: 1, Foo.someLiteral: "a" | "b" | "c"someLiteral: 'a', Foo.object: Record<string, any>object: { key: stringkey: 'value' } }, { Foo.num: numbernum: 2, Foo.someLiteral: "a" | "b" | "c"someLiteral: 'a', Foo.object: Record<string, any>object: { key: stringkey: 'diffValue' } }, { Foo.num: numbernum: 1, Foo.someLiteral: "a" | "b" | "c"someLiteral: 'b', Foo.object: Record<string, any>object: {} }, ]; var console: Consoleconsole.Console.dir(item?: any, options?: any): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir)
dir
(const _: import("/home/runner/work/JRMurr.github.io/JRMurr.github.io/node_modules/@types/lodash/index.d.ts").LoDashStatic_.LoDashStatic.groupBy<Foo>(collection: _.List<Foo> | null | undefined, iteratee?: _.ValueIteratee<Foo> | undefined): _.Dictionary<Foo[]> (+1 overload)
Creates an object composed of keys generated from the results of running each element of collection through iteratee. The corresponding value of each key is an array of the elements responsible for generating the key. The iteratee is invoked with one argument: (value).
@paramcollection The collection to iterate over.@paramiteratee The function invoked per iteration.@returnReturns the composed aggregate object.
groupBy
(const vals: Foo[]vals, 'num'));
/* { '1': [ { num: 1, someLiteral: 'a' }, { num: 1, someLiteral: 'b' } ], '2': [ { num: 2, someLiteral: 'a' } ] } */ var console: Consoleconsole.Console.dir(item?: any, options?: any): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir)
dir
(const _: import("/home/runner/work/JRMurr.github.io/JRMurr.github.io/node_modules/@types/lodash/index.d.ts").LoDashStatic_.LoDashStatic.groupBy<Foo>(collection: _.List<Foo> | null | undefined, iteratee?: _.ValueIteratee<Foo> | undefined): _.Dictionary<Foo[]> (+1 overload)
Creates an object composed of keys generated from the results of running each element of collection through iteratee. The corresponding value of each key is an array of the elements responsible for generating the key. The iteratee is invoked with one argument: (value).
@paramcollection The collection to iterate over.@paramiteratee The function invoked per iteration.@returnReturns the composed aggregate object.
groupBy
(const vals: Foo[]vals, 'someLiteral'));
/* { a:[ { num: 1, someLiteral: 'a', object: [Object] }, { num: 2, someLiteral: 'a', object: [Object] } ], b: [ { num: 1, someLiteral: 'b', object: {} } ] } */

This all seems to make sense, you can set what key you want to group on, and you get back an object whose keys are the values for found in the input array of objects.

Now if you're in a TypeScript code base I hope you are using the definitely typed lodash types to add some types to the lodash functions. In this case the _.groupBy type looks roughly like (simplified from the actual code)

declare function function groupBy<T>(collection: Array<T>, key: string): Dictionary<T[]>groupBy<function (type parameter) T in groupBy<T>(collection: T[], key: string): Dictionary<T[]>T>(collection: T[]collection: interface Array<T>Array<function (type parameter) T in groupBy<T>(collection: T[], key: string): Dictionary<T[]>T>, key: stringkey: string): interface Dictionary<T>Dictionary<function (type parameter) T in groupBy<T>(collection: T[], key: string): Dictionary<T[]>T[]>;

interface interface Dictionary<T>Dictionary<function (type parameter) T in Dictionary<T>T> {
  [index: stringindex: string]: function (type parameter) T in Dictionary<T>T;
}

So a few things stick out here. First, the key type is just string, so there's nothing stopping me from doing _.groupBy(vals, "someKeyThatDoesNotExist"). Second, we have no restrictions at the type level of me grouping on a key whose value is not a valid object key (the value must be a subset of string | number | symbol). For example in Foo the object key's value was a record. Here's what happens when you try to group on that key.


var console: Consoleconsole.Console.dir(item?: any, options?: any): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir)
dir
(const _: import("/home/runner/work/JRMurr.github.io/JRMurr.github.io/node_modules/@types/lodash/index.d.ts").LoDashStatic_.LoDashStatic.groupBy<Foo>(collection: _.List<Foo> | null | undefined, iteratee?: _.ValueIteratee<Foo> | undefined): _.Dictionary<Foo[]> (+1 overload)
Creates an object composed of keys generated from the results of running each element of collection through iteratee. The corresponding value of each key is an array of the elements responsible for generating the key. The iteratee is invoked with one argument: (value).
@paramcollection The collection to iterate over.@paramiteratee The function invoked per iteration.@returnReturns the composed aggregate object.
groupBy
(const vals: Foo[]vals, 'object'));
/* { '[object Object]': [ { num: 1, someLiteral: 'a', object: [Object] }, { num: 2, someLiteral: 'a', object: [Object] }, { num: 1, someLiteral: 'b', object: {} } ] } */

In this case the objects where coerced to string values so all elements of vals where grouped under the same weird [object Object] key. While this does not throw an error there is almost 0 chance you want this to happen in your code.

Finally, the return type of this function is Dictionary, while its "right" it could be "more right" by encoding that the returning object's keys would be the values of the grouping key in the input object.

Making our own groupBy

insert Bender joke here

To start making our own type safe groupBy, we first need some code that actually does the grouping logic. Let's start with that and some basic types.

// Note: PropertyKey is a builtIn type alias of
// type PropertyKey = string | number | symbol
// This lets us use "Record<PropertyKey, any>" to represent any object
// but is slightly nicer to use than the "object" type
function function simpleGroupBy<T extends Record<PropertyKey, any>>(arr: T[], key: keyof T): anysimpleGroupBy<function (type parameter) T in simpleGroupBy<T extends Record<PropertyKey, any>>(arr: T[], key: keyof T): anyT extends type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<type PropertyKey = string | number | symbolPropertyKey, any>>(arr: T[]arr: function (type parameter) T in simpleGroupBy<T extends Record<PropertyKey, any>>(arr: T[], key: keyof T): anyT[], key: keyof Tkey: keyof function (type parameter) T in simpleGroupBy<T extends Record<PropertyKey, any>>(arr: T[], key: keyof T): anyT): any {
return arr: T[]arr.Array<T>.reduce<any>(callbackfn: (previousValue: any, currentValue: T, currentIndex: number, array: T[]) => any, initialValue: any): any (+2 overloads)
Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((accumulator: anyaccumulator, val: T extends Record<PropertyKey, any>val) => {
const const groupedKey: T[keyof T]groupedKey = val: T extends Record<PropertyKey, any>val[key: keyof Tkey]; if (!accumulator: anyaccumulator[const groupedKey: T[keyof T]groupedKey]) { accumulator: anyaccumulator[const groupedKey: T[keyof T]groupedKey] = []; } accumulator: anyaccumulator[const groupedKey: T[keyof T]groupedKey].push(val: T extends Record<PropertyKey, any>val); return accumulator: anyaccumulator; }, {} as any); } var console: Consoleconsole.Console.dir(item?: any, options?: any): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir)
dir
(function simpleGroupBy<Foo>(arr: Foo[], key: keyof Foo): anysimpleGroupBy(const vals: Foo[]vals, 'num'));
/* { '1': [ { num: 1, someLiteral: 'a', object: [Object] }, { num: 1, someLiteral: 'b', object: {} } ], '2': [ { num: 2, someLiteral: 'a', object: [Object] } ] } */

Cool the logic here seems to work, but obviously the types could use some love.

Let's start by adding a few more generics, so we can type the output correctly. Your first change might be to make the return type Record<string, T[]> since the keys will be coerced to strings by JavaScript and the values will be the same values in the array. This will unfortunately make typescript sad.

function function sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]>sadAttempt<function (type parameter) T in sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]>T extends object>(arr: T[]arr: function (type parameter) T in sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]>T[], key: keyof Tkey: keyof function (type parameter) T in sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]>T): type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, function (type parameter) T in sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]>T[]> {
return arr: T[]arr.Array<T>.reduce<Record<string, T[]>>(callbackfn: (previousValue: Record<string, T[]>, currentValue: T, currentIndex: number, array: T[]) => Record<string, T[]>, initialValue: Record<...>): Record<...> (+2 overloads)
Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((accumulator: Record<string, T[]>accumulator, val: T extends objectval) => {
const const groupedKey: T[keyof T]groupedKey = val: T extends objectval[key: keyof Tkey]; if (!accumulator[groupedKey]) {
Type 'T[keyof T]' cannot be used to index type 'Record<string, T[]>'.
accumulator[groupedKey] = [];
Type 'T[keyof T]' cannot be used to index type 'Record<string, T[]>'.
} accumulator[groupedKey].push(val: T extends objectval);
Type 'T[keyof T]' cannot be used to index type 'Record<string, T[]>'.
return accumulator: Record<string, T[]>accumulator; }, {} as type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, function (type parameter) T in sadAttempt<T extends object>(arr: T[], key: keyof T): Record<string, T[]>T[]>);
}

The lines with accumulator[groupedKey] will error with Type 'T[keyof T]' cannot be used to index type 'Record<string, T>'. Here the keyof T could be any key in T so since not every key's value in T is a string typescript will not let you treat groupedKey as a string.

We can almost fix this by adding some more information on the input key by binding it to a new generic parameter, though there will still be some issues.

function function betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>betterSadAttempt<function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T extends type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<type PropertyKey = string | number | symbolPropertyKey, any>, function (type parameter) Key in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>Key extends keyof function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T>(
arr: T[]arr: function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T[], key: Key extends keyof Tkey: function (type parameter) Key in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>Key ): type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T[function (type parameter) Key in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>Key], function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T[]> {
return arr: T[]arr.Array<T>.reduce<Record<T[Key], T[]>>(callbackfn: (previousValue: Record<T[Key], T[]>, currentValue: T, currentIndex: number, array: T[]) => Record<T[Key], T[]>, initialValue: Record<...>): Record<...> (+2 overloads)
Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((accumulator: Record<T[Key], T[]>accumulator, val: T extends Record<PropertyKey, any>val) => {
const const groupedKey: T[Key]groupedKey = val: T extends Record<PropertyKey, any>val[key: Key extends keyof Tkey]; if (!accumulator: Record<T[Key], T[]>accumulator[const groupedKey: T[Key]groupedKey]) { accumulator: Record<T[Key], T[]>accumulator[const groupedKey: T[Key]groupedKey] = []; } accumulator: Record<T[Key], T[]>accumulator[const groupedKey: T[Key]groupedKey].Array<T>.push(...items: T[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@paramitems New elements to add to the array.
push
(val: T extends Record<PropertyKey, any>val);
return accumulator: Record<T[Key], T[]>accumulator; }, {} as type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T[function (type parameter) Key in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>Key], function (type parameter) T in betterSadAttempt<T extends Record<PropertyKey, any>, Key extends keyof T>(arr: T[], key: Key): Record<T[Key], T[]>T[]>);
}

Here we added a new generic Key extends keyof T so when we supply a specific key to the function, the Key generic will be narrowed to that value. For example if we did betterSadAttempt(vals, 'someLiteral'), Key would exactly be 'someLiteral' instead of keyof Foo = 'someLiteral' | 'num' | 'object'

However, typescript is still sad on the Record<T[Key], T[]> lines with Type 'T[Key]' does not satisfy the constraint 'string | number | symbol'. This error is similar to the error before, basically T[Key] can not be a key for the Record since it could be some weird value.

To accomplish this we need to make a helper type that filters down the allowed keys to only keys whose values are string | number | symbol. We can use a mapped type to do just that

type type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<function (type parameter) T in type MapValuesToKeysIfAllowed<T>T> = {
  [function (type parameter) KK in keyof function (type parameter) T in type MapValuesToKeysIfAllowed<T>T]: function (type parameter) T in type MapValuesToKeysIfAllowed<T>T[function (type parameter) KK] extends type PropertyKey = string | number | symbolPropertyKey ? function (type parameter) KK : never;
};
type type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]Filter<function (type parameter) T in type Filter<T>T> = type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<function (type parameter) T in type Filter<T>T>[keyof function (type parameter) T in type Filter<T>T];

This type helper does a few things. First it maps over all the values in T ([K in keyof T]) and makes the value the key if it is a subset of string | number | symbol (T[K] extends PropertyKey ? K), if it's not a subset its value will be the never type. Finally, we use an index access type to get all values of the transformed object as a union. This step will drop all the never values automatically for us since adding never to a union is like saying or false its basically is a no op.

That was a mouthful so let's see an example

// from above
interface Foo {
  Foo.num: numbernum: number;
  Foo.someLiteral: "a" | "b" | "c"someLiteral: 'a' | 'b' | 'c';
  Foo.object: Record<string, any>object: type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, any>;
} type type MappedFoo = { num: "num"; someLiteral: "someLiteral"; object: never; }MappedFoo = type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<Foo>; /* { num: "num"; someLiteral: "someLiteral"; object: never; } */ // we replace the values of this object with just the key as a string literal or never type type FooKeys = "num" | "someLiteral"FooKeys = type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]Filter<Foo>; // => "num" | "someLiteral" // notice the never does not show up in the union interface AllObjects { AllObjects.object: Record<string, any>object: type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, any>;
AllObjects.diffObject: Record<number, any>diffObject: type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<number, any>;
} type type MappedAllObjects = { object: never; diffObject: never; }MappedAllObjects = type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<AllObjects>; /* { object: never; diffObject: never; } */ type type AllObjectsKeys = neverAllObjectsKeys = type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]Filter<AllObjects>; // => never // the output is only never. Think of this like saying "false or false", the output will just be false

With this filter type helper function we can now properly limit the Key generic by replacing Key extends keyof T with Key extends Filter<T>.

Putting it all together


type type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<function (type parameter) T in type MapValuesToKeysIfAllowed<T>T> = {
  [function (type parameter) KK in keyof function (type parameter) T in type MapValuesToKeysIfAllowed<T>T]: function (type parameter) T in type MapValuesToKeysIfAllowed<T>T[function (type parameter) KK] extends type PropertyKey = string | number | symbolPropertyKey ? function (type parameter) KK : never;
};
type type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]Filter<function (type parameter) T in type Filter<T>T> = type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<function (type parameter) T in type Filter<T>T>[keyof function (type parameter) T in type Filter<T>T];

function function groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>groupBy<function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T extends type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<type PropertyKey = string | number | symbolPropertyKey, any>, function (type parameter) Key in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>Key extends type Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]Filter<function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T>>(
arr: T[]arr: function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T[], key: Key extends Filter<T>key: function (type parameter) Key in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>Key ): type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T[function (type parameter) Key in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>Key], function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T[]> {
return arr: T[]arr.Array<T>.reduce<Record<T[Key], T[]>>(callbackfn: (previousValue: Record<T[Key], T[]>, currentValue: T, currentIndex: number, array: T[]) => Record<T[Key], T[]>, initialValue: Record<...>): Record<...> (+2 overloads)
Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((accumulator: Record<T[Key], T[]>accumulator, val: T extends Record<PropertyKey, any>val) => {
const const groupedKey: T[Key]groupedKey = val: T extends Record<PropertyKey, any>val[key: Key extends Filter<T>key]; if (!accumulator: Record<T[Key], T[]>accumulator[const groupedKey: T[Key]groupedKey]) { accumulator: Record<T[Key], T[]>accumulator[const groupedKey: T[Key]groupedKey] = []; } accumulator: Record<T[Key], T[]>accumulator[const groupedKey: T[Key]groupedKey].Array<T>.push(...items: T[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@paramitems New elements to add to the array.
push
(val: T extends Record<PropertyKey, any>val);
return accumulator: Record<T[Key], T[]>accumulator; }, {} as type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T[function (type parameter) Key in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>Key], function (type parameter) T in groupBy<T extends Record<PropertyKey, any>, Key extends Filter<T>>(arr: T[], key: Key): Record<T[Key], T[]>T[]>);
} const const nums: Record<number, Foo[]>nums = function groupBy<Foo, "num">(arr: Foo[], key: "num"): Record<number, Foo[]>groupBy(const vals: Foo[]vals, 'num'); // nums = Record<number, Foo[]> const const literals: Record<"a" | "b" | "c", Foo[]>literals = function groupBy<Foo, "someLiteral">(arr: Foo[], key: "someLiteral"): Record<"a" | "b" | "c", Foo[]>groupBy(const vals: Foo[]vals, 'someLiteral'); // literals = Record<"a" | "b" | "c", Foo[]> const const sad: Record<number | "a" | "b" | "c", Foo[]>sad = function groupBy<Foo, Filter<Foo>>(arr: Foo[], key: Filter<Foo>): Record<number | "a" | "b" | "c", Foo[]>groupBy(const vals: Foo[]vals, 'object');
Argument of type '"object"' is not assignable to parameter of type 'Filter<Foo>'.

Now this works great, we can only pass in keys that have valid values, and we even get autocomplete on it! However, one thing that bothers me is the error message in the last case. While it's correct, saying not assignable to parameter of type 'Filter<Foo>' is not very useful to a user. This pops up sometimes with typescript where it won't show the underlying type and instead just show the higher level type helper instead.

To make the error message show the valid keys we can use a modified version of this "hack". Here instead of creating the Expand type in the post, we can make our own ValuesOf to replace the [keyof T] at the end of Filter

type type ValuesOf<A> = A extends infer O ? A[keyof A] : neverValuesOf<function (type parameter) A in type ValuesOf<A>A> = function (type parameter) A in type ValuesOf<A>A extends infer function (type parameter) OO ? function (type parameter) A in type ValuesOf<A>A[keyof function (type parameter) A in type ValuesOf<A>A] : never;

type type Filter<T> = MapValuesToKeysIfAllowed<T> extends infer O ? (O & MapValuesToKeysIfAllowed<T>)[keyof T | keyof O] : neverFilter<function (type parameter) T in type Filter<T>T> = type ValuesOf<A> = A extends infer O ? A[keyof A] : neverValuesOf<type MapValuesToKeysIfAllowed<T> = { [K in keyof T]: T[K] extends PropertyKey ? K : never; }MapValuesToKeysIfAllowed<function (type parameter) T in type Filter<T>T>>;
// was Filter<T> = MapValuesToKeysIfAllowed<T>[keyof T]

const const sad: Record<number | "a" | "b" | "c", Foo[]>sad = function groupBy<Foo, "num" | "someLiteral">(arr: Foo[], key: "num" | "someLiteral"): Record<number | "a" | "b" | "c", Foo[]>groupBy(const vals: Foo[]vals, 'object');
Argument of type '"object"' is not assignable to parameter of type '"num" | "someLiteral"'.

Now we have type safety and good error messages!

Possible improvements

One thing this groupBy function lacks that the lodash groupBy gives is we do not allow you to pass a function instead of a key to group on. The example in the lodash docs is

const _: import("/home/runner/work/JRMurr.github.io/JRMurr.github.io/node_modules/@types/lodash/index.d.ts").LoDashStatic_.LoDashStatic.groupBy<number>(collection: _.List<number> | null | undefined, iteratee?: _.ValueIteratee<number> | undefined): _.Dictionary<number[]> (+1 overload)
Creates an object composed of keys generated from the results of running each element of collection through iteratee. The corresponding value of each key is an array of the elements responsible for generating the key. The iteratee is invoked with one argument: (value).
@paramcollection The collection to iterate over.@paramiteratee The function invoked per iteration.@returnReturns the composed aggregate object.
groupBy
([6.1, 4.2, 6.3], var Math: Math
An intrinsic object that provides basic mathematics functionality and constants.
Math
.Math.floor(x: number): number
Returns the greatest integer less than or equal to its numeric argument.
@paramx A numeric expression.
floor
);
// { '4': [4.2], '6': [6.1, 6.3] }

While this is not perfect this mostly works

function function groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>groupByFunc<
  function (type parameter) RetType in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>RetType extends type PropertyKey = string | number | symbolPropertyKey,
  function (type parameter) T in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>T // no longer need any requirements on T since the grouper can do w/e it wants
>(arr: T[]arr: function (type parameter) T in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>T[], mapper: (arg: T) => RetTypemapper: (arg: Targ: function (type parameter) T in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>T) => function (type parameter) RetType in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>RetType): type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<function (type parameter) RetType in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>RetType, function (type parameter) T in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>T[]> {
return arr: T[]arr.Array<T>.reduce<Record<RetType, T[]>>(callbackfn: (previousValue: Record<RetType, T[]>, currentValue: T, currentIndex: number, array: T[]) => Record<RetType, T[]>, initialValue: Record<...>): Record<...> (+2 overloads)
Calls the specified callback function for all the elements in an array. The return value of the callback function is the accumulated result, and is provided as an argument in the next call to the callback function.
@paramcallbackfn A function that accepts up to four arguments. The reduce method calls the callbackfn function one time for each element in the array.@paraminitialValue If initialValue is specified, it is used as the initial value to start the accumulation. The first call to the callbackfn function provides this value as an argument instead of an array value.
reduce
((accumulator: Record<RetType, T[]>accumulator, val: Tval) => {
const const groupedKey: RetType extends PropertyKeygroupedKey = mapper: (arg: T) => RetTypemapper(val: Tval); if (!accumulator: Record<RetType, T[]>accumulator[const groupedKey: RetType extends PropertyKeygroupedKey]) { accumulator: Record<RetType, T[]>accumulator[const groupedKey: RetType extends PropertyKeygroupedKey] = []; } accumulator: Record<RetType, T[]>accumulator[const groupedKey: RetType extends PropertyKeygroupedKey].Array<T>.push(...items: T[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@paramitems New elements to add to the array.
push
(val: Tval);
return accumulator: Record<RetType, T[]>accumulator; }, {} as type Record<K extends string | number | symbol, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<function (type parameter) RetType in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>RetType, function (type parameter) T in groupByFunc<RetType extends PropertyKey, T>(arr: T[], mapper: (arg: T) => RetType): Record<RetType, T[]>T[]>);
} const const test: Record<number, number[]>test = function groupByFunc<number, number>(arr: number[], mapper: (arg: number) => number): Record<number, number[]>groupByFunc([6.1, 4.2, 6.3], var Math: Math
An intrinsic object that provides basic mathematics functionality and constants.
Math
.Math.floor(x: number): number
Returns the greatest integer less than or equal to its numeric argument.
@paramx A numeric expression.
floor
);
// test = Record<PropertyKey, Foo[]>

This works by only letting you pass in functions that return PropertyKey, and typescript even narrows the types. In this case test is Record<number, Foo[]> since TS infers the return type of the grouping function.

If you know how to improve this function further feel free to leave an issue/pr on my blog's GitHub!