-
Notifications
You must be signed in to change notification settings - Fork 17
Maybe also have groupByMap
?
#3
Comments
My other thought is that we could have a 3rd argument similar to |
That opens up a bunch of questions - what if the init value is a Set, or a TypedArray (on the array method), or a Tuple, or a Record? |
Interesting thought. To me I think that adds a lot of conceptual complexity for not much value, vs just having two methods. |
One option (similar to what @jridgewell was proposing) is to have the extra parameter expect an object with an "emplace" function (emplace for Map/WeakMap is proposed here). All update operations will happen through the provided emplace function. If this extra parameter is not provided, it'll default to special behavior for grouping into a plain object. const getParity = i => i % 2 === 0 ? 'even': 'odd'
const mapOfResults = array.groupBy(getParity, new Map());
const weakMapOfResults = array.groupBy(getParity, new WeakMap()); Alternatively, we could create an "emplace" or "groupBy" protocol (via a new well-known symbol) that Map and WeakMap simply implement using their public emplace function, but others may implement through other means. My preference might be to make this parameter expect an options object, to open up the possibility of customizing other aspects of the groupBy algorithm in the future. const mapOfResults = array.groupBy(getParity, { into: new Map() }); |
I'm against adding a reducer argument since it makes the signature of this method more complex and less readable. Also, objects and maps / weakmaps haven't a common interface for adding new entries.
const weakmap = new WeakMap(array.groupByMap(fn)); |
I actually think only 1 method would be enough at least for the initial proposal, then you can manage your identity side table rather than spec having nits about things like -0 and 0. |
I agree @bmeck - honestly, I'm not sure how many people would want a map as an output (maybe a lot - maybe my assumptions are wrong, but I would assume the majority just need an object output). People who need map support can build their own groupByMap() function, like they already have to do today. If there seems to be a lot of community need for such functionality, a follow-on proposal can be discussed to provide this functionality, either through an extra parameter or through a separate function. |
I'd personally love a Map rather than an object, but I use Maps a lot to associate data. |
@bmeck const { foo, bar, baz } = array.groupBy(fn);
// ...
for (const [name, subarray] of array.groupByMap(fn)) { /* ... */ } Both of those cases are useful, map can't be simply used in the first case, object in the second. |
Object is usable in the second with |
The only thing that's not usable in the second case is if you want the "group by" key to be something other than a string or symbol. Personally, i think this will be an extreme minority case, but it still seems reasonable to find a way to satisfy it. |
I want this quite frequently, personally. The most common case is when you want to bucket things which have two attributes which match - for example, you have a list of addresses and want to bucket those which have both the same city and country. If your cities and countries are represented as strings, you can come up with an awkward solution which creates a single string from both of those, but that's ugly and error-prone (if you just join with (Also, it's pretty common to have attributes which are not strings which you want to match on. For example, even if you just care about grouping addresses by city, if your cities are represented as objects with identity you really need a Map.) |
Without Records existing as a feature yet, would that be any more convenient than the string approach? |
Yeah, you just have a memoizing "Pair" constructor which returns the same identity object given the same inputs, which approximates Records. In nontrivial code I will often have such a thing around already, but it's only a few lines to reproduce it as necessary: let memo = new Map;
function makePair(left, right) {
if (!memo.has(left)) {
memo.set(left, new Map);
}
let lm = memo.get(left);
if (!lm.has(right)) {
lm.set(right, [left, right]);
}
return lm.get(right);
}
let grouped = addresses.groupByMap(a => makePair(a.city, a.country)); |
What if instead |
That doesn't really work, because That is: [{ a: 0 }, { a: '0' }].groupBy(o => o.a); // { 0: [{ a: 0 }, { a: '0' }] }
Object.fromEntries([{ a: 0 }, { a: '0' }].groupByEntries(o => o.a)); // { 0: [{ a: '0' }] }, the first entry has been eaten Also, point of this thread is that the object form is much more ergonomic for the common case. If you have to do |
@bakkot it'd work if the coalescing was done inside groupBy - that way the only choice left would be if the Totally agree tho that needing to Object.fromEntries busts up the ergonomics. |
Well, yes, but if you're going to be passing an argument to determine whether to coalesce-like-for-objects or coalesce-like-for-maps, you might as well just go all the way and produce an object or map at that point anyway, rather than a list of entries. |
Although, one alternative would be to default to an object - the common case - and allow a simple argument that instead produces entries - so you can make a Map, Set, WeakMap, or whatever you want? |
It's already trivial to get entries out of a Map, and that's the overwhelmingly common thing to want when not just doing a plain object. So I'd prefer to just produce a Map, and let people consume that however they like if they want it in a different form. |
For the non-object case, assuming there is an object option, that seems like a reasonable position. |
I should have written an example. I was thinking more like with the grouping done for you: [{ a: 0 }, { a: '0' }].groupBy(o => o.a); // [ [0, [{ a: 0 }]], ['0', [{ a: '0' }]] ] Or the example from the readme: const array = [1, 2, 3, 4, 5];
// groupBy groups items by arbitrary key.
// In this case, we're grouping by even/odd keys
array.groupBy(i => {
return i % 2 === 0 ? 'even': 'odd';
});
// => [ ['odd', [1, 3, 5]], ['even', [2, 4]] ] I believe passing this result to Object.fromEntries([ ['odd', [1, 3, 5]], ['even', [2, 4]] ])
// => { odd: [1, 3, 5], even: [2, 4] } And would also work with Map: new Map([ ['odd', [1, 3, 5]], ['even', [2, 4]] ])
// => Map(2) { 'odd' => [1, 3, 5], 'even' => [2, 4] } This way you use existing Object & Map methods and open your self up to other data structures, instead of requiring a parameter. And the top level result doesn’t necessarily have to be an array, as these methods accept any iterable (not sure if that opens up any optimizations). |
Yes, that particular example works, but the example I gave does not. If [{ a: 0 }, { a: '0' }].groupBy(o => o.a); // [ [0, [{ a: 0 }]], ['0', [{ a: '0' }]] ] then Object.fromEntries([{ a: 0 }, { a: '0' }].groupBy(o => o.a)); // { 0: [{ a: '0' }] }, the first entry has been eaten as you can tell by doing Object.fromEntries([ [0, [{ a: 0 }]], ['0', [{ a: '0' }]] ]) today. |
Just to clarify, your particular example is highlighting how a Map may be better than an Object? I agree, it does sound like a better use. What I was trying to advocate for was a destination-agnostic method, that can easily be used to create a Map, an Object, or some future immutable JavaScript data type. I believe Iterables work fantastically as a bridge between different data types, especially as they can be lazily evaluated. It feels as though this capability is quite often forgotten in JavaScript’s APIs, even new ones. |
What @bakkot is trying to explain is that you can't create a destination agnostic method. The destination affects the key equality mechanism. For Object, we need to use |
IMO using Object.fromEntries(new Map([[1,1],[2,2]])) So why not What if users do want to make const map = [{ a: 0 }, { a: '0' }].groupByMap(o => o.a + '');
map.get('0') // => [{ a: 0 }, { a: '0' }]
map.get(0) // => undefined
Object.fromEntries(map) // => { '0': [{ a: 0 }, { a: '0' }] } In addition, we have another proposal: https://github.com/tc39/proposal-collection-normalization So we can also do: const map = [{ a: 0 }, { a: '0' }].groupByMap(o => o.a, { coerceKey: String });
map.get('0') // => [{ a: 0 }, { a: '0' }];
map.get(0) // => [{ a: 0 }, { a: '0' }];
Object.fromEntries(map) // => { '0': [{ a: 0 }, { a: '0' }] } |
As a counter-example, compare these two code snippets: // Example 1
const { minors, adults } = users.groupBy(u => u.age > 18 ? 'adults' : 'minors')
...
// Example 2
const minorsAndAdults = users.groupByMap(u => u.age > 18 ? 'adults' : 'minors')
const minors = minorsAndAdults.get('minors')
const adults = minorsAndAdults.get('adults')
... I understand there's use cases for grouping by a map and not coercing keys, but I would be against not having a groupByObject option. Javascript developers understand that objects keys are always strings, and shouldn't be surprised by the coercion here any more than the coercing that happens wehe you do |
You could also write your Example 2 more concisely as const { minors, adults } = Object.fromEntries(users.groupByMap(u => u.age > 18 ? 'adults' : 'minors')) but since we expect this to be a very common thing to want to do, it seems silly to require the user to provide the |
I brought this up in #9, but I'll quote it here since it somewhat crosses into both issues:
|
The explainer references lodash's
|
I don't think making the object iterable solves this problem. const data = [true, 'true']
data.groupBy(x => x) // { 'true': [true, 'true'] }
data.groupByMap(x => x) // Map { true => [true], 'true' => 'true'] } If the returned object from data.groupBy were iterable, I would expect that it would yield entries that looked like As for your links to how other languages do it - for most of those languages, they don't really have the option to use an object the same way we do in Javascript, so it's hard to compare against them saying "look, they don't use a Javascript-like object as an output" when that's not even an option for them. But, it is intriguing that some of them choose to use give a list of tuples instead of whatever map structure they do have - not sure the reasoning behind that. |
@rbuckton Most of those languages do not have a first-class record type like JavaScript does and so are not directly comparable. In JavaScript libraries, the convention is very strongly to return a string-keyed object. See That said, I agree that having a Map is strictly more useful, despite breaking with the ecosystem precedent and being less ergonomic, which is why I propose just having two versions. Do you see a downside to that? (I don't think your proposal to expose a |
I suppose I'm not opposed to two implementations. The only downside of producing a import { from } from "@esfx/iter-query";
from([true, "true", false])
.groupBy(x => `${x}`)
.toObject(null, ({ key }) => key, ({ values }) => values.toArray()); // { true: [true, 'true'], false: [false] }
from(array)
.groupBy(x => `${x}`)
.toMap(({ key }) => key, ({ values }) => values.toArray()); // Map { 'true' => [true, 'true'], 'false' => [false] }
from(array)
.groupBy(x => `${x}`)
.map(({ key, values }) => `key: ${key}, values: ${JSON.stringify(values.toArray())}`)
.toArray()
.join(`;`); // 'key: true, values: [true,"true"]; key: false, values: [false]' It may take awhile to get there with various proposals, but I'm hoping we do (at least in some form). |
It's coming: https://github.com/tc39/proposal-collection-methods#proposal |
@CarterLi Unfortunely, it isn't coming - it's basically stalled due to subclassing reasons. |
BTW, personally I think that we should have
|
@Ginden that presumes the most common use case would be a Map, when i strongly suspect it'll be the Object. |
I feel strongly that the object version of this method should call |
Closing with the committee consensus on |
I'm way too late to the party but what about adding the second argument to We can't do it for |
Unfortunately, With
BTW, @jridgewell, you can add to Related section: https://tc39.es/proposal-collection-methods/#Map.groupBy |
None of the examples I listed are subclasses of Map, they don't need to be as long as their constructor accepts an array of entries |
@ephys @Ginden it's rather a case for something like that tc39/proposal-iterator-helpers#36 |
Yes something somewhat like Edit: we should move this to a new issue. I commented here as I was initially going to talk about dropping groupByToMap in favor of the constructor but keeping both makes sense so it's unrelated to this thread. More related to #15 |
One thing I believe was not discussed at the plenary and which was not delved into deeply here is that since assert(Object.is(Object.asKey(0), '0'));
assert(Object.is(Map.asKey(-0), 0));
const arr = [{ a: 0 }, { a: '0' }, {a: -0}];
Object.fromEntries(arr.groupByToEntries(o => Object.asKey(o.a))); // { '0': [{ a: 0 }, { a: '0' }, {a: -0}] };
const map = new Map(arr.groupByToEntries(o => Map.asKey(o.a)));
map.get('0'); // => [{ a: '0' }]
map.get(0); // => [{ a: 0 }, { a: -0 }] |
In the interest of having both the convenience of regular objects and the additional utility of Maps available, albeit not in the same object, perhaps we can have a method which returns a plain object (coalescing return values with ToPropertyKey) as well as a method which returns a Map (coalescing only 0 and -0, and otherwise by identity).
The text was updated successfully, but these errors were encountered: