0

I am trying to take in an array of names (with duplicates) and output a array of objects.

Input: single array

const arr = ['M', 'M', 'M', 'S', 'S', 'S', 'R', 'C', 'S', 'S', 'S']

Output: array of objects

[
  {
    name: 'M'
    duplicates: 3
  },
  {
    name: 'S'
    duplicates: 6
  }...
]

I have tried this solution, but this is giving me the name of each array item for the key name. That is not what I want.

This is my current code:

const result = arr.reduce((acc, curr) => {
    acc[curr] ??
        (acc[curr] = {
            [curr]: 0,
        })
    acc[curr][curr]++
    return acc
}, [])
console.log(result)

/*output:
[
  C: {C: 1}
  M: {M: 3}
  R: {R: 1}
  S: {S: 6}
]
*/
Tyler Morales
  • 1,440
  • 2
  • 19
  • 56

2 Answers2

2

Index the accumulator by the element, increment counts for matching keys, strip out the index (with Object.values) at the end...

const arr = ['M', 'M', 'M', 'S', 'S', 'S', 'R', 'C', 'S', 'S', 'S'];

let result = Object.values(arr.reduce((acc, el) => {
  if (!acc.hasOwnProperty(el)) acc[el] = { name: el, duplicates: 0 };
  acc[el].duplicates++;
  return acc;
}, {}));


console.log(result)
danh
  • 62,181
  • 10
  • 95
  • 136
1

I'd like to discuss a harder way to do this.

Yes, that's right, harder! But that's only if you're building this up from scratch. If you already have a library of utility functions, it could be much simpler.

We will end up writing a transformation function that looks like this:

const transform = pipe (
  countBy (identity),
  Object .entries,
  map (zipObj (['name', 'duplicates']))
)

The idea is that with a number of small utility functions, we can write such a function in a simpler, declarative manner, describing it as a set of transformations.

Below we start building our own list of utility functions. These ones are modelled after functions from Ramda (disclaimer: I'm a Ramda author) but we write our own versions here. Our functions will be simpler than those in Ramda, and somewhat less powerful. But they will cover a great number of use-cases and are simple to extend.

Step 1: count

The first thing we want to do is to count the elements by key.

We want to take

['M', 'M', 'M', 'S', 'S', 'S', 'R', 'C', 'S', 'S', 'S']

and covert it into

{M: 3, S: 6, R: 1, C: 1}

We can write this function as a simple reduction over our values, like this1:

const count = (xs) => 
  xs .reduce ((a, x) => ((a [x] = (a [x] || 0) + 1), a), Object .create (null))

We could stop there, but it's really easy to see some useful extensions to this. Perhaps we don't care about case, and want to count all the lowercase ms together with the uppercase Ms. Or we have a list of people with their birthdays, and we want to count them by birth year. Or any of a number of scenarios. If we extended that function to also take a transformation function, we could cover all these at once. It might look like this2:

const countBy = (fn) => (xs) =>
  xs .reduce ((a, x) => ((a [fn (x)] = (a [fn (x)] || 0) + 1), a), Object .create (null))

And we might use it like this:

const decade = ({dob}) => `${dob .slice (0, 3)}0-${dob .slice (2, 3)}9`
const people = [{name: 'sue', dob: '1950-10-31'}, {name: 'bob', dob: '1967-04-28'},
                {name: 'jan', dob: '1972-02-26'}, {name: 'ron', dob: '1966-01-05'},
                {name: 'lil', dob: '1961-04-17'}, {name: 'tim', dob: '1958-09-12'}]

countBy (decade) (people) //=> {"1950-59": 2, "1960-69": 3, "1970-79": 1}

Now we can rewrite count to use this by passing an identity function to countBy. identity is the trivial (x) => x, which turns out to be surprisingly useful:

const count = countBy (identity)

Step 2: Separating the properties

So now we have {M: 3, S: 6, R: 1, C: 1}, but we need to turn this into an array of objects, one for each property. There is a built-in JS function for this, Object.entries. (Ramda has it's own version, toPairs, which predates Object.entries, but there is no reason now not to use the built-in one.)

So then we can do

Object .entries ({M: 3, S: 6, R: 1, C: 1})

to get back

[['M', 3], ['S', 6], ['R', 1], ['C', 1]]

Step 3: Converting to final form, using zipObj

Now we want to convert, say ['M', 3] to {name: 'M', duplicates: 3}.

One nice possibility is to write a zip* function that zips together two equal length lists into a new structure. There are many possible variants, but let's write one that simply takes a list of keys and a same-sized list of values, and pairs them up as entries of an object3:

const zipObj = (ks) => (vs) =>
  ks .reduce ((a, k, i) => ((a [k] = vs [i]), a), {})

and with this, we can call

zipObj (['name', 'duplicates']) (['M', 3])
//=> {name: 'M', duplicates: 3}

Step 4: mapping that result over all entries

We can now convert a single entry into its final form, but we have to do this with an array full of such entries. We know how to do that using Array.prototype.map. But there are two problems with using it directly. One is a bit obscure: Array.prototype.map passes additional parameters (the index and the whole array) besides our initial values. There are times that can cause issues. The second problem is simple: for the style promoted here, pure functions work much more nicely than object methods.

So we write a plain function map, which simply calls Array.prototype.map4:

const map = (fn) => (xs) =>
  xs .map ((x) => fn (x))

and we can use it like this:

map (zipObj (['name', 'duplicate'])) (
  [['M', 3], ['S', 6], ['R', 1], ['C', 1]]
)

which yields the final result.

It seems strange that after noting flaws in Array.prototype.map, we use it in the implementation of map, but I believe it makes sense. We solve the second problem noted by the very fact that we're defining map. And the first one is solved simply by managing Array.prototype.map, passing only the value into it. Ramda rewrites these from scratch, and in doing so can eke out a bit more performance, but over time that performance edge is eroded, and the code there is much more cumbersome.

Step 5: pipeing these functions together

To make this a nice declarative set of steps, we need one more piece: a way to glue the functions together, to run one after another. My image is of a pipeline through which the data flows, and so use pipe for this function. This is a very close kin to the mathematical notion of function composition, and Ramda includes both pipe and compose, which work very similarly but list their arguments in opposite orders.

pipe is a simple function:

const pipe = (...fns) => (x) =>
  fns .reduce ((a, fn) => fn (a), x)

We simply iterate over the functions, passing the result of the previous call to the next function, starting with the value passed in.

And with this, we can now put it all together.

Step 6: Combining our functions

With all of the above, we now can write a solution to the original problem:

const countBy = (fn) => (xs) =>
  xs .reduce ((a, x) => ((a [fn (x)] = (a [fn (x)] || 0) + 1), a), Object .create (null))
const identity = (x) => 
  x
const count = countBy (identity)
const zipObj = (ks) => (vs) =>
  ks .reduce ((a, k, i) => ((a [k] = vs [i]), a), {})
const map = (fn) => (xs) =>
  xs .map ((x) => fn (x))
const pipe = (...fns) => (x) =>
  fns .reduce ((a, fn) => fn (a), x)

const transform = pipe (
  countBy (identity),
  Object .entries,
  map (zipObj (['name', 'duplicates']))
)

const arr = ['M', 'M', 'M', 'S', 'S', 'S', 'R', 'C', 'S', 'S', 'S']

console .log (transform (arr))
console .log (transform (['toString', 'toString', 'valueOf']))
.as-console-wrapper {max-height: 100% !important; top: 0}

Note that the only custom code here is transform. The rest is utility functions we can keep around for other parts of our system and for future systems.

Lessons

  • Small helper functions can be combined in powerful ways to make solutions to our larger problems easier to write.

  • It pays to relentlessly break down code into smaller and smaller bits until we find those helper functions lurking in the problem.

  • Once you have a significant collection of helper functions, it can become very quick to solve such problems. I actually wrote my solution for this in Ramda in under two minutes. The final version only differed in adding count, where the Ramda version requires countBy (identity) and in my use of Ramda's toPairs instead of Object.entries.

Open Questions

  • Performance: This is almost certainly not as performant as the answer from danh. This one ends up looping over the data several times. There are times when this is a real concern. You will have to decide for your specific circumstances whether cleaner code is worth some inefficiencies. There is no universal answer. But I tend to prefer to run with the simplest code I can. If my system speed does not meet my criteria, I profile to find the most important bottlenecks and solve those first. Only very rarely has it come down to altering code like this.

  • API design: I took the requested output as a hard requirement here. And it may be; I don't know the OP's needs. But I would find the output of countBy, {M: 3, S: 6, R: 1, C: 1} to be a more useful structure for most processing. I would suggest always checking whether simpler structures might capture ones needs better than customized ones.



1 The use of Object .create (null) instead of {} allows us to be able to handle the issue georg raised in a comment to another answer. By creating with a null prototype, we avoid spurious matches in our accumulator object for properties such as 'toString'.

2 We should probably note the inefficiency in countBy: it calculates fn(x) twice. It's simple enough to fix this, but it takes us away from our main points here. Honestly, I often don't bother, either.

3 There is a whole family of functions that involve zipping two equal-sized arrays. If we extend this, eventually we'll probably rewrite zipObj on top of a more generic zipWith, the way we rewrote count above atop countBy. That's left as an exercise for the reader.

4 Eventually, we might want to extend this function to also work on Objects, and perhaps other types. But this is enough for now.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • 1
    Really appreciate this long form answer. This has given me a lot to think about! However, for my use case, @danh's answer provided simplicity and ease, but maybe your answer will prove useful in later applications. – Tyler Morales Oct 04 '21 at 21:41