Skip to content

Instantly share code, notes, and snippets.

@almostSouji
Last active October 19, 2024 10:10
Show Gist options
  • Save almostSouji/71a33e5f8e84a0960b4ed3a1609915f5 to your computer and use it in GitHub Desktop.
Save almostSouji/71a33e5f8e84a0960b4ed3a1609915f5 to your computer and use it in GitHub Desktop.
Discord.js: Collections but Cookies

Discord.js Collections explained with cookies

Intro

I keep seing people that have issues with understanding our Collection class.

Note

Boiled down to essentials, Collection just extends the base JavaScript Map class with Array-like methods. If that's enough for you to understand, you can stop reading here.

Have you ever tried to think like a program? What I mean by that is: Thinking about real world objects and how a program might interact with these objects can help fundamentally understanding intricacies that are hard to understand, if you just read the documention. If you haven't, let's change that!

You are working at a cookie store. Customers come in and ask you to retrieve cookies from storage (and there is also your boss, that gives you menial, annoying tasks - sounds too much like reality? oops.)

The store has a lot of boxes, you might say... a Collection of them.

Is this a terrible example to explain Collections with? Probably.
Will that stop me? Nope!

"No, seriously, why the heck don't you explain this with anything that actually applies to Discord.js? Members, Messages, anything...?!"

Well, hear me out. If I do this for members people will ask "how the heck does it work for channels, I'm so lost." Members and Channels are rather hard to grasp since they're abstract - you can't touch them and they translate poorly to real life.

Cookies dough (got it, "dough", bc... cookies...) Cookies are amazing. Not only can you touch them you can even eat them!

So: Understand this cookie analogy and you can apply it to any Collection in and outside of Discord.js! Instantly makes this sound more worthwhile, right? Great, let's start!

The shape of (cookie) Collections

The base structure in our example (in discord.js this wil be User, Role, TextChannel, etc.) is the Cookie. Like discord.js objects, Cookies have various properties you can sort and filter them by. A cookie instance might look something like this:

Cookie {
  flavor: 'Chocolate Chip',
  diameter: 5,
  shape: 'round',
  bakedTimestamp: 1531959750264,
  ingredients: ['chocolate', 'sugar', 'butter', 'eggs', 'vanilla', 'baking soda', 'flour', 'salt'],
  price: 3.50,
}

Multiple cookies might be packed together inside a box. A cookie box has a timestamp of when it was packed .packedTimestamp and - obviously - a collection of the cookies inside the box, .cookies. The structure may look something like this:

CookieBox {
  cookies: Collection [Map],
  packedTimestamp: 1531959750264,
}

The .cookies inside the box are a Collection. Collections, as Maps, map a specific, unique, key to a value. In this case, we use the .bakedTimestamp of each cookie as key. This is the same in discord.js! Most Collections are keyed by the objects .id property. Discord ids follow the Snowflake standard, which also includes the timestamp, so we're not too far off with this cookie example!

Collection [Map] {
  1531959750264 => Cookie,
  1531959842329 => Cookie,
  1531959862265 => Cookie,
  1531959880410 => Cookie,
  1531959888759 => Cookie }

Now we add another layer. Our cookie stash has multiple boxes, and since we are already invested into Collections, let's use them for this, too! As key we use the .packedTimestamp of each box.

Your cookieStash.boxes collection will look something like this:

Collection [Map] {
  1531959750264 => CookieBox,
  1531959842329 => CookieBox,
  1531959862265 => CookieBox,
  1531959880410 => CookieBox,
  1531959888759 => CookieBox }

Setup done, now let's get to work!

Collection.size

Collections have a property .size which returns - you guessed it - the amount of elements inside the Collection.
So following the thought experiment cookieStash.boxes.size would return the number of cookieBoxes in our stash. So that'd be how we find out when we need to bake again, by looking up the amount of boxes, who would've thought!

Note

You do not count the boxes every time! The collection has an inherent counter that gets updated every time element are added or removed. This is somewhat important as counting things would take you going through the collection element by element otherwise, which is very inefficient!

read more: Map#size

Collection.get

"So... we have a problem, the box that was packed at 1531959750264 is missing the quality seal, could you retrieve it from the stack in the back please?"

Heckin sure you can! Since our Collection of boxes is mapped by creation timestamp you can simply use the Map method .get(key) to retrieve the corresponding box. The key here is said timestamp.

So you simply go to the back, look at the big list on the shelf and retrieve the box in the slot corresponding to the timestamp. Easy.

Note

You don't go to the back and read each and every box, you simply get the right one. This is a way faster operation than reading the labels on all the boxes.

Important

Always use .get() if you want to retrieve by key, in Discord.js most collections are keyed by id (as a string)

const requestedBox = cookieBoxes.get('1531959750264')
// returns the box mapped to 1531959750264

reference: Map#get

Collections.first

Give me the first two cookie boxes from the stash please

Okay. You go to the stash, get the first two box and take them to your boss. Done.

const requestedBox = cookieBoxes.first(2)
// returns the first x boxes (in this case 2)

Note

If you call .first() without parameter, it returns the element itself. With a number, it returns an array of elements, even in the case of .first(1)!

Collection.find

"I really want a box with at least three cookies, feeling rather peckish today!"

No worries, we got you! Remember that cookies are also a Collection inside our Collection of cookieBoxes. And you know how to check the size of Collections already so let's go.

You go to the back, but you don't have a list with how many cookies each box contains.

You take the first box from the stash, attach it the label "thisBox" (just a piece of paper you stick on top of it, so you can reference it - yeah, nobody would ever do that, but in code you will need it, so I fear you have to do this silly thing for now, sorry)

So you take the box, stick the "thisBox" paper on it, open it and count the cookies inside. If the box satisfies our validation ("does it have at least 3 cookies") you return that box to the customer, they pay, profit. If the box does not satisfy our validation (has 0, 1 or 2 cookies) you grab the next box, attach the thisBox paper to it, open it...

You do this until you find a match or you run out of boxes.

const requestedBox = cookieBoxes.find(thisBox => thisBox.cookies.size >= 3)
// returns the first matching box with at least 3 cookies in it

reference: Array#find

Collection.filter

Can you get me all boxes containing 2 or more cookies?

Yes, sure! No biggie!

You take the first box, stick the "thisBox" paper on it, open it, and count the cookies inside. If the box satisfies our validation ("does it have at least 2 cookies?") you stack them aside. If the box does not satisfy our validation (has 0 or 1 cookies) you grab the next box, attach the thisBox paper to it, open it...

You do this until you run out of boxes and return the new found Collection of filtered boxes to the storefront.

Note

This should sound somewhat familiar! The process is quite similar to .find()! Make sure you understand the difference here. .find() will return the first match (and stop at that point), whereas .filter() will go through the entire collection and add matching elements to a new collection, which it then returns (the filtered result). One returns an element, the other returns a collection.

const requestedBoxes = cookieBoxes.filter(thisBox => thisBox.cookies.size >= 2)
// returns all boxes with at least 2 cookies in it

Collection.has

"Is the box that was packed at 1531959750264 still in the stash?

You go back to our list of boxes, check if the one keyed to 1531959750264 is still there, and shout a "YES" to the front. We just returned a boolean, true and... yeah, that's exactly how Map.has() works.

const boolean = cookieBoxes.has('1531959750264')
// returns true or false depending on if the specific box is in the collection or not

reference: Map#has

Collection.some

"Do we still have a box with oatmeal-raisin cookies in it, or do i need to bake some?"

You know... you are talking about oatmeal-raisin cookies, the answer to the second part of that question is "NO" and does not depend on the first part. Well, let's check. A job is a job.

You go to the cookie stash, but the outside label doesn't have the flavors of cookies inside the box. So you need to open all the boxes until you find one that satisfies the validation. Maybe we should rethink the storage system...

You take the first box, stick the "thisBox" paper on it, open it, and check if it has shivers oatmeal-raisin cookies. Double collection time yaaay! If the box satisfies the validation ("does it have oatmeal-raisin cookies?") you shout "YES WE HAVE SOME" to the store front. If the box does not satisfy our validation (has no oatmeal-raisin cookies) you grab the next box, attach the thisBox paper to it, open it...

You do this until you find a match or you run out of boxes. If you did not find a match, there are no oatmeal-raising cookies in the stash. (This would usually be considered a good thing, but your boss probably expects "NO WE HAVE NONE". Some people seem to like those things.)

const boolean = cookieBoxes.some(thisBox => thisBox.cookies.some(thisCookie => thisCookie.flavor === "Oatmeal Raisin"))
// returns true or false depending on if any box in the collection fulfills the validating function

Note

Try to understand the nested function here. Maybe the following phrasing helps: We are checking the stash for SOME box where SOME cookie has the flavor "Oatmeal Raisin". You might have realized that this operates like find(). The difference is, that find() returns the element that was found, some() just returns a boolean.

reference: Array#some

Collection.map

Remember me saying we need a list of cookie flavors to put on the outside of boxes? No? Don't skip half of the guide then. No worries though, you want a list of cookie flavors to put on the box.

How do you do this? Right, you open a box and write each cookies flavor on a piece of paper. Isn't the job of a program amazing?

const cookieFlavors = cookieBox.map(thisCookie => thisCookie.flavor)
// returns an array of cookie flavors for cookies inside the box

You could also make a list of flavors and price, if you want to be really fancy

const boxLabel = cookieBox.map(thisCookie => `${thisCookie.flavor} | ${thisCookie.price}`)
// returns an array of strings "flavor | price" for each cookie in the box

reference: Array#map

Collection.random

"All your cookies are amazing, just grab some box, I'm fine with everything!"

Okay... do i really need to explain that one? We go to the back and get one. Badaboom badabaaang done. random can also take a number, if the customer wants more than one random box.

const requestedBox = cookieBoxes.random()
// returns a random box
const requestedBoxes = cookieBoxes.random(3)
// returns an array of as much random boxes as specified (3)

Collection.values

"Grab all boxes we have left, i want to go over them"

Grab all... ALL BOXES?! Do you know how heckin heavy that is?! Yeah, just grab them all without their timestamp labels. Yeah. okay. the analogy breaks here. or... crumbles? (because cookies y'know) It returns a Map iterator.

const allBoxesButIterable = cookieBoxes.values()
// returns a map iterator for the box collection

reference: Map#values

Collection.keyArray

"Give me a list of all of our boxes packing times please"

Go to the back, grab the list, make a copy of it and hand it to your boss. Easy job, done.

const keyArray = cookieBoxes.keyArray()
// returns an array of timestamps (because that's what our boxes are keyed with)

Collection.sort

"Please sort the boxes by their timestamp and get me the oldest

Sorting... oh boy. Fun. Okay. Let's go. Collection.sort works like Array.sort

You get a compare function, "by their timestamp starting with the oldest" and sort Boxes by it. Take two boxes, compare them, if the validating function returns something lower than 0 sort the first box lower, if it returns 0 don't change the alignment of those two, but sort them in relation to the other boxes, if it returns a number bigger than 0 sort the first box higher than the second one.

Subtract timestamps from two boxes at a time and sort them. I don't want to go into more specifics, check the MDN page if you are interested in that stuff.

const requestedBox = cookieBoxes.sort((thisBox, thatBox) => thisBox.timestamp - thatBox.timestamp).first()
// sort collections starting with the oldest and returns the first element

Collection.reduce

"How many cookies do we have in all boxes?"

Oh boy, more fun. Open all boxes and add the amount of cookies up. Just tedious. Better keep track of this, grab a piece of paper, write "ACCUMULATOR" on it, because that's exactly what this paper will act as. Now write a 0 under it, that's our starting value. Attach the "thisBox" label to the first box, open it, count the cookies, add it to the starting value, note that down. Take the second box, attach the "thisBox" label to it, open it, count the cookies, add it to the number on the paper and so on. You are done when there are no boxes left, obviously.

const cookieCount = cookieBoxes.reduce((accumulator, thisBox) => accumulator + thisBox.cookies.size, 0)
// returns the amount of cookies in all boxes

Iterating collections

"I wanted to do this myself but something came up, can you go through the boxes and attach a new price to them, since they're getting kind of old"

Yeah sure "SOMETHING" came up... You take the first box, attach the thisBox label to it, strike through the old price and add the new one. Because who wants old cookies, right? But if they were like... cheap? Do this until there are no boxes left.

Remember that iterator thing from Collection.values()? We need this now. So Collection.values it is!

for (const thisBox of cookieBoxes.values()) {
  thisBox.price *= 0.5
}
// iterate over all boxes and half the price, cookie sale *yay*
// *= is just a fancy shortcut for thisBox.price = thisBox.price * 0.5

Combined cookie madness

I want the three oldest boxes containing no chocolate chip cookies that also contain blueberry cookies.

What is wrong with you?!

Nope, i won't write the analogy for this. no. Filter boxes, sort them, get the three first. All stuff we went over.

Look, I'll break it down, so it's not as scary.

const filtered = cookieBoxes.filter(thisBox => !thisBox.cookies.some(thisCookie => thisCookie.flavor === "Chocolate Chip") && this.cookies.some(thisCookie => thisCookie.flavor === "Blueberry"))
const sorted = filtered.sort((thisBox, thatBox) => thisBox.timestamp - thatBox.timestamp)
const requestedBoxes = sorted.first(3)
// returns the three oldest boxes with no chocolate chip cookies that also have at least one blueberry cookie

I mean... we can also throw it all together if you like pain that much.

const requestedBoxes = cookieBoxes.filter(thisBox => !thisBox.cookies.some(thisCookie => thisCookie.flavor === "Chocolate Chip") && this.cookies.some(thisCookie => thisCookie.flavor === "Blueberry")).sort((thisBox, thatBox) => thisBox.timestamp - thatBox.timestamp).first(3)
// returns the three oldest boxes with no chocolate chip cookies that also have at least one blueberry cookie

Wasn't even that bad, was it?

Now you know how Collections work, go and apply it to whatever demands your bot may throw at you!

@peakh
Copy link

peakh commented Jan 18, 2023

Love the entertaining story that goes along with this. Excellent breakdown of each segment as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment