You'll implement groupBy, a function that takes an array and a way to derive a key from each element, then returns an object whose values are arrays of elements that share the same key. It's the same shape as lodash's _.groupBy — useful any time you want to bucket a list (orders by status, files by extension, transactions by month).
type Iteratee<T> = ((value: T) => unknown) | string;
function groupBy<T>(
array: T[],
iteratee: Iteratee<T>
): Record<string, T[]>;
The iteratee is either a function called with each element, or a string property key looked up on each element. Whatever it returns becomes the bucket name (coerced to a string).
// Function iteratee — bucket by floor
groupBy([1.2, 1.4, 2.1, 2.7], Math.floor);
// → { '1': [1.2, 1.4], '2': [2.1, 2.7] }
// Property-key iteratee — bucket by the value at obj[key]
const people = [
{ name: 'Ada', team: 'eng' },
{ name: 'Grace', team: 'eng' },
{ name: 'Linus', team: 'ops' },
];
groupBy(people, 'team');
// → { eng: [{name:'Ada',...}, {name:'Grace',...}], ops: [{name:'Linus',...}] }
Math.floor returning 2 becomes the key '2'. An iteratee returning undefined produces the bucket 'undefined'.groupBy([], anything) returns {}.iteratee is a string, treat it as a property name: the bucket key is element[iteratee]. You can ignore symbol keys.You'll build a small dispatcher that walks the array once, asks the iteratee for a key for each element, and drops the element into the matching bucket — creating the bucket on first sight.
You have a flat list and you want it sorted into named piles. Orders by status (pending, shipped, cancelled); files by extension; transactions by month. The list itself doesn't know the labels — you derive each element's label from a function (or, as a shorthand, the name of a property on the element). Same shape as lodash's _.groupBy: in goes an array, out comes an object whose values are arrays of the originals.
Imagine the array on the left and an empty object on the right. You walk the array left-to-right. For each element, the iteratee whispers a key. If a bucket with that key doesn't exist yet on the object, create it as an empty array. Then push the element. That's it.
There's one twist: the iteratee can come in two flavours. Either it's a function we call, or it's a string property name we look up. The cleanest move is to normalise the string form to a function up front — then the rest of the algorithm only deals with one shape.
Reaching for the most familiar tool — forEach plus a fresh result object — gets you here:
function groupByBroken(array, iteratee) {
const result = {};
array.forEach((element) => {
const key = iteratee(element); // assumes iteratee is a function
result[key].push(element); // assumes the bucket already exists
});
return result;
}
This crashes on the very first element. result[key] is undefined because we never initialised the bucket, and calling .push on undefined throws TypeError: Cannot read properties of undefined. It also assumes iteratee is callable — pass 'team' and the first line of the loop throws iteratee is not a function. Two separate bugs, both fixable in two lines each.
function groupBy(array, iteratee) {
// Normalise: if iteratee is a string, turn it into a property-lookup function.
// From here on, `getKey` is always a function — the loop body has one shape.
const getKey =
typeof iteratee === 'function'
? iteratee
: (element) => element[iteratee];
// Plain `{}` inherits from Object.prototype. That's fine for typical use;
// see Gotchas for when you'd want Object.create(null) instead.
const result = {};
for (const element of array) {
const key = getKey(element); // key may be number, string, undefined, etc.
// First time we see this key? Initialise the bucket. We rely on JS coercing
// the key to a string automatically when used as an object property.
if (!Object.prototype.hasOwnProperty.call(result, key)) {
result[key] = [];
}
result[key].push(element); // preserve input order — push is append-to-end
}
return result;
}
module.exports = { groupBy };
Three meaningful shifts from the naive version. First, we normalise the iteratee so the loop never has to branch on its type. Second, we guard against the missing-bucket case before pushing — hasOwnProperty.call rather than result[key] === undefined, so a literal undefined value in a bucket can't be mistaken for "bucket missing." Third, we use a plain for...of loop instead of .forEach so a return could short-circuit if we ever needed it (and it reads as well or better).
Concrete inputs: groupBy([1.2, 1.4, 2.1, 2.7], Math.floor).
typeof Math.floor === 'function', so getKey is just Math.floor. result starts as {}. Then the loop walks:
1.2 — getKey(1.2) = 1. result has no own property '1', so we set result[1] = [], then push: { '1': [1.2] }.1.4 — getKey(1.4) = 1. result already has '1', skip the init, push: { '1': [1.2, 1.4] }.2.1 — getKey(2.1) = 2. No '2' bucket yet, create it, push: { '1': [1.2, 1.4], '2': [2.1] }.2.7 — getKey(2.7) = 2. Push into the existing bucket: { '1': [1.2, 1.4], '2': [2.1, 2.7] }.Return { '1': [1.2, 1.4], '2': [2.1, 2.7] }. Note the object keys are strings — JavaScript object keys always are. Whether you read result[1] or result['1'], you get the same bucket because the numeric key was coerced to '1' on the way in.
Complexity: one pass through the array, one constant-time bucket lookup and push per element. O(n) time, O(n) space.
result[key].push(element) without first checking the bucket exists is the original naive bug. The first element with any new key throws TypeError. Always create the empty array before the first push, e.g. result[key] ??= []; result[key].push(element);.result[key] === undefined for the existence check — looks reasonable but lies if a bucket happens to be [undefined] and you'd already overwritten it. More relevantly, it picks up inherited properties; Object.prototype.hasOwnProperty.call(result, key) is the safe form. If you'd rather sidestep the issue entirely, use Object.create(null) for result so there's no Object.prototype to inherit from.undefined — totally legal; the bucket key becomes the string 'undefined'. Looks weird in the output ({ undefined: [...] }) but is exactly what lodash does. Don't filter these out silently — the caller may want to know.array.sort(...) to "group by sorting" changes the input array in place. Stay non-destructive; the caller likely doesn't expect their list to be reordered as a side effect of asking for buckets.undefined on missing properties — groupBy([{x:1},{y:2}], 'x') puts the second element into bucket 'undefined', not into an empty bucket and not skipped. Same rule as a function iteratee returning undefined; this trips people who expect "missing property" to mean "skip the element."(team, role) together. Compose the key as `${team}|${role}` inside the iteratee, or build a tree of nested objects (lodash's _.groupBy doesn't do this, but _.countBy-with-tuples patterns do).keyBy variant — same shape but each bucket holds a single element instead of an array; the last element with a given key wins. Useful when keys are unique identifiers.[key, partialBucket] pairs as the input streams in, rather than building the entire object in memory. Real-world use: log lines bucketed by hour as they tail in.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You'll implement groupBy, a function that takes an array and a way to derive a key from each element, then returns an object whose values are arrays of elements that share the same key. It's the same shape as lodash's _.groupBy — useful any time you want to bucket a list (orders by status, files by extension, transactions by month).
type Iteratee<T> = ((value: T) => unknown) | string;
function groupBy<T>(
array: T[],
iteratee: Iteratee<T>
): Record<string, T[]>;
The iteratee is either a function called with each element, or a string property key looked up on each element. Whatever it returns becomes the bucket name (coerced to a string).
// Function iteratee — bucket by floor
groupBy([1.2, 1.4, 2.1, 2.7], Math.floor);
// → { '1': [1.2, 1.4], '2': [2.1, 2.7] }
// Property-key iteratee — bucket by the value at obj[key]
const people = [
{ name: 'Ada', team: 'eng' },
{ name: 'Grace', team: 'eng' },
{ name: 'Linus', team: 'ops' },
];
groupBy(people, 'team');
// → { eng: [{name:'Ada',...}, {name:'Grace',...}], ops: [{name:'Linus',...}] }
Math.floor returning 2 becomes the key '2'. An iteratee returning undefined produces the bucket 'undefined'.groupBy([], anything) returns {}.iteratee is a string, treat it as a property name: the bucket key is element[iteratee]. You can ignore symbol keys.You'll build a small dispatcher that walks the array once, asks the iteratee for a key for each element, and drops the element into the matching bucket — creating the bucket on first sight.
You have a flat list and you want it sorted into named piles. Orders by status (pending, shipped, cancelled); files by extension; transactions by month. The list itself doesn't know the labels — you derive each element's label from a function (or, as a shorthand, the name of a property on the element). Same shape as lodash's _.groupBy: in goes an array, out comes an object whose values are arrays of the originals.
Imagine the array on the left and an empty object on the right. You walk the array left-to-right. For each element, the iteratee whispers a key. If a bucket with that key doesn't exist yet on the object, create it as an empty array. Then push the element. That's it.
There's one twist: the iteratee can come in two flavours. Either it's a function we call, or it's a string property name we look up. The cleanest move is to normalise the string form to a function up front — then the rest of the algorithm only deals with one shape.
Reaching for the most familiar tool — forEach plus a fresh result object — gets you here:
function groupByBroken(array, iteratee) {
const result = {};
array.forEach((element) => {
const key = iteratee(element); // assumes iteratee is a function
result[key].push(element); // assumes the bucket already exists
});
return result;
}
This crashes on the very first element. result[key] is undefined because we never initialised the bucket, and calling .push on undefined throws TypeError: Cannot read properties of undefined. It also assumes iteratee is callable — pass 'team' and the first line of the loop throws iteratee is not a function. Two separate bugs, both fixable in two lines each.
function groupBy(array, iteratee) {
// Normalise: if iteratee is a string, turn it into a property-lookup function.
// From here on, `getKey` is always a function — the loop body has one shape.
const getKey =
typeof iteratee === 'function'
? iteratee
: (element) => element[iteratee];
// Plain `{}` inherits from Object.prototype. That's fine for typical use;
// see Gotchas for when you'd want Object.create(null) instead.
const result = {};
for (const element of array) {
const key = getKey(element); // key may be number, string, undefined, etc.
// First time we see this key? Initialise the bucket. We rely on JS coercing
// the key to a string automatically when used as an object property.
if (!Object.prototype.hasOwnProperty.call(result, key)) {
result[key] = [];
}
result[key].push(element); // preserve input order — push is append-to-end
}
return result;
}
module.exports = { groupBy };
Three meaningful shifts from the naive version. First, we normalise the iteratee so the loop never has to branch on its type. Second, we guard against the missing-bucket case before pushing — hasOwnProperty.call rather than result[key] === undefined, so a literal undefined value in a bucket can't be mistaken for "bucket missing." Third, we use a plain for...of loop instead of .forEach so a return could short-circuit if we ever needed it (and it reads as well or better).
Concrete inputs: groupBy([1.2, 1.4, 2.1, 2.7], Math.floor).
typeof Math.floor === 'function', so getKey is just Math.floor. result starts as {}. Then the loop walks:
1.2 — getKey(1.2) = 1. result has no own property '1', so we set result[1] = [], then push: { '1': [1.2] }.1.4 — getKey(1.4) = 1. result already has '1', skip the init, push: { '1': [1.2, 1.4] }.2.1 — getKey(2.1) = 2. No '2' bucket yet, create it, push: { '1': [1.2, 1.4], '2': [2.1] }.2.7 — getKey(2.7) = 2. Push into the existing bucket: { '1': [1.2, 1.4], '2': [2.1, 2.7] }.Return { '1': [1.2, 1.4], '2': [2.1, 2.7] }. Note the object keys are strings — JavaScript object keys always are. Whether you read result[1] or result['1'], you get the same bucket because the numeric key was coerced to '1' on the way in.
Complexity: one pass through the array, one constant-time bucket lookup and push per element. O(n) time, O(n) space.
result[key].push(element) without first checking the bucket exists is the original naive bug. The first element with any new key throws TypeError. Always create the empty array before the first push, e.g. result[key] ??= []; result[key].push(element);.result[key] === undefined for the existence check — looks reasonable but lies if a bucket happens to be [undefined] and you'd already overwritten it. More relevantly, it picks up inherited properties; Object.prototype.hasOwnProperty.call(result, key) is the safe form. If you'd rather sidestep the issue entirely, use Object.create(null) for result so there's no Object.prototype to inherit from.undefined — totally legal; the bucket key becomes the string 'undefined'. Looks weird in the output ({ undefined: [...] }) but is exactly what lodash does. Don't filter these out silently — the caller may want to know.array.sort(...) to "group by sorting" changes the input array in place. Stay non-destructive; the caller likely doesn't expect their list to be reordered as a side effect of asking for buckets.undefined on missing properties — groupBy([{x:1},{y:2}], 'x') puts the second element into bucket 'undefined', not into an empty bucket and not skipped. Same rule as a function iteratee returning undefined; this trips people who expect "missing property" to mean "skip the element."(team, role) together. Compose the key as `${team}|${role}` inside the iteratee, or build a tree of nested objects (lodash's _.groupBy doesn't do this, but _.countBy-with-tuples patterns do).keyBy variant — same shape but each bucket holds a single element instead of an array; the last element with a given key wins. Useful when keys are unique identifiers.[key, partialBucket] pairs as the input streams in, rather than building the entire object in memory. Real-world use: log lines bucketed by hour as they tail in.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.