-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add types for Object.groupBy() and Map.groupBy() #56805
Conversation
Co-authored-by: Nick McCurdy <[email protected]>
I’d probably also credit @karlhorky and @nikeee ❤️ |
Co-authored-by: Karl Horky <[email protected]> Co-authored-by: Niklas Mollenhauer <[email protected]>
Done. Hope that's alright @karlhorky @nikeee. |
@typescript-bot pack this |
Heya @DanielRosenwasser, I've started to run the tarball bundle task on this PR at 547e844. You can monitor the build here. |
Hey @DanielRosenwasser, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running There is also a playground for this build and an npm module you can use via |
This seems good overall, though users will have to opt in to the more precise inference for keys. I would also think to stick with the same precedent we have elsewhere for type parameter names ( I'll let others weigh in. |
What do you mean by "more precise"?
Similarly
SGTM, done. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks right, just one question about Partial.
groupBy<K extends PropertyKey, T>( | ||
items: Iterable<T>, | ||
keySelector: (item: T, index: number) => K, | ||
): Partial<Record<K, T[]>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is the return type Partial
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because you might not actually get all of the keys.
As a trivial example, consider the case that the iterable is empty. Then the resulting record will have no keys at all. Typing as Record<K, T[]>
would mean that e.g. Object.groupBy(vals, x => x < 5 ? 'small' : 'large')
would be typed as always having small
and large
keys, such that result.small.length
would check without errors, which is misleading.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, right, I forgot that Map<K, T[]>
has get: (k: K) => T[] | undefined
so was confused by the difference in types.
@bakkot I'll merge this after you merge from main (or rebaseline, whichever). |
@sandersn Done. |
Hi, I just noticed this and think it needs some improvements, since it doesn't handle cases like these: function tester<K extends number | string>(k: K, index: number): K extends number ? 'numbers' : 'strings' {
return (typeof k === 'number') ? 'numbers' : 'strings';
}
// expected numbers ✅
const a1 = tester(123, 123);
// ^? const a1: "numbers"
// expected strings ✅
const a2 = tester('123', 123);
// ^? const a1: "strings"
// expected error ✅
const a3 = tester(true, 123);
// Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
const basic = Object.groupBy([0, 2, 8, 'asd'], tester);
// ^? const basic: Partial<Record<"numbers" | "strings", (string | number)[]>>
// expected number[] | undefined ❌
basic.numbers
// ^? (property) numbers?: (string | number)[] | undefined
// expected string[] | undefined ❌
basic.strings
// ^? (property) numbers?: (string | number)[] | undefined |
@nikelborm Typescript's types don't generally go for that level of type-level logic. It might be possible in theory but it would significantly complicate the types. The current types aren't wrong, just slightly imprecise. |
It neither does "hard" logic function tester(k: number | string, index: number): k is string {
return typeof k === 'string';
}
// expected {'true'?: string[], 'false'?: number[]}
const basic = Object.groupBy([0, 2, 8, 'asd'], tester);
// Type 'boolean' is not assignable to type 'PropertyKey'. nor easy (it doesn't seems hard to support just booleans) function tester2(k: number | string, index: number): boolean {
return typeof k === 'string';
}
// expected Partial<Record<"true" | "false", (number | string)[]>>
const basic2 = Object.groupBy([0, 2, 8, 'asd'], tester2);
// Type 'boolean' is not assignable to type 'PropertyKey'. It's very common use case to divide an array of something into 2 groups like this: const { true:strings, false: numbers} = Object.groupBy([0, 2, 8, 'asd'], (k) => typeof k === 'string'); It doesn't have to be as simple as checking is this a string or not. It may cover many other business logic use cases const people = [{name: 'Julie', age: 15}, {name: 'John', age: 23}]
const { true: adults, false: children } = Object.groupBy(people, (person) => person.age >= 18); |
@nikelborm You're welcome to send a followup PR and see if the TS team accepts it. |
@sandersn what do you think about this? |
Hey there 🙂 You’ve raised 2 separate issues here:
|
Returning Partial See for example: We now have to deal with entries with undefined values, which technically is not really possible in practice. Isn't there a way to express a Record where "only keys are Partial", but values are always defined when the key exists? I tried various things but not sure to have a good solution. Here's my last attempt: type SubsetRecord<K extends PropertyKey, T> = {
[P in K]?: T;
};
declare function groupBy<K extends PropertyKey, T>(
items: Iterable<T>,
keySelector: (item: T, index: number) => K,
): SubsetRecord<K, T[]>
type User = {name: string,age: number, type: "a" | "b" | "c"}
const users: User[] = [{name: "Seb",age: 42, type: "a"}]
const groups = groupBy(users, u => u.type);
// ✅ entries do not have undefined
Object.entries(groups) satisfies [string,User[]][]
// ✅
groups["a"]!.toSorted()
// ✅
groupBy(users,u => "Seb")["Seb"]!.toSorted()
// ❌ should be forbidden: value can be undefined
// @ts-expect-error
groups["a"].toSorted()
// ❌ should be forbidden because keys do not match
// @ts-expect-error
groupBy(users,u => "Seb")["blabla"]
// ❌ should be forbidden: value can be undefined
// @ts-expect-error
groupBy(users,u => "Seb")["Seb"].toSorted() |
I agree with this. In cases where the type of the group key This all comes down to TypeScript's limits to separate if the property is there at all vs. if they exist with value However, imo the developer experience would be greatly improved if the signature of interface ObjectConstructor {
/**
* Groups members of an iterable according to the return value of the passed callback.
* @param items An iterable.
* @param keySelector A callback which will be invoked for each item in items.
*/
groupBy<K extends PropertyKey, T>(
items: Iterable<T>,
keySelector: (item: T, index: number) => K,
): string extends K ? Record<K, T[]> : Partial<Record<K, T[]>>; // 👈 changed return type
} The change is on the return value of the method: it now conditionally returns the regular "dictionary" type, e.g. Now, the previously mentioned unnecessary This return type still matches the implementation, but is more useful in cases where the grouping key is just the broad type of Also, with this change, it also leaves up to the developer to choose their What do you think? |
I think having the types incorrectly suggest that every key is present is worse than having the types incorrectly suggest that keys which are present might have value So I do not think that would be a good change. |
Fixes #47171.
The tests are pretty simple because the types are simple, but I'm happy to expand them.
Try them on the playground.