In javascript, when we're thinking about "groupings of like things," we tend to think of arrays. And that's not wrong, arrays are powerful, intentful, and very useful. But they're also pretty basic.
When we start building projects, we often find ourselves reaching for a way to group things, but to do so at a higher level than that of an array. It might be handy to add to a grouping without having to consider how it is being implemented. It might be useful to find an thing by an id, for example, or to be able to implement a find
function such as the array uses, while not necessarily using an array to store that stuff internally.
We want to abstract the data structuring away within a Collection
, and simply provide a useful interface that we can consume.
What might a Collection
look like?
We define the Collection
, from the outside, by what it does. What behaviors does it have? How can we talk to it? What can it tell us, and what can we tell it? Let's start by defining some basic methods:
Collection:
.add( item )
.find( findFunction )
.findAll()
.update( updateFunction )
.remove( item )
That's the basic, core functionality we might want. The idea is, a collection should contain instances of the same type of thing, making our find
or update
pretty universal (for a collection of Book
objects, we might want a find
function that can find by ISBN for all of them).
So we might use it like this:
const playlist = Collection();
playList.add(Song('Si Mi Clean', 'Buju Banton', '3:00'));
playlist.add(Song("Eleanor Rigby - 2022 Mix", "The Beatles", "2:06"));
// we can find things...
const aSong = playlist.find( song => song.title.startsWith("Si")[0];
// and we can update things.
playList.update(aSong.id, song => song.setLike(true) )
So we might create that basic functionality:
const Collection = (title='Default Collection') => {
let stuff = [];
const add = (item)=> {
stuff = [...stuff, item];
}
const remove = (item) => {
stuff = stuff.filter(thing => thing != item);
}
const find = (func) => stuff.filter( func );
const findAll = () => [...stuff];
const update = (updateFunc)=>stuff=stuff.map(updateFunc)
return {
get title(){ return title;},
add,
remove,
find,
findAll,
update
}
}
Looks pretty clean, does what we want... but its not great. I mean, really, all it actually does is wrap an array, and act as an intermediary to an array. It's not like we are adding any functionality to it, right?
So what's the point?
The Point
Let's revisit the original idea, and use a more concrete example. Suppose we have a Library
- it's just a collection. It holds a variety of media, and when queried, can retrieve a unique thing, can interact with that thing, can update or remove it. We can add items to the Library
, without needing to sweat "Hey, so how is this thing unique?"
If we were to implement that, we might create a Book
factory (or constructor, up to you):
const Book = (
title='Default title',
author='Default Author',
publicationDate=Date.now(),
pages=0
) => {
const toString = () => `${title} by ${author} (${pages}pp)`
return {
get title(){ return title },
get author(){ return author },
get publicationDate(){ return publicationDate },
get pages(){ return pages },
toString
}
}
So that defines the basic structure of a Book
: title, author, publication date, pages. And as far as it goes, it's exactly what we'd need.
But if you were to go down that path in one of the online courses you might be advised, "Okay, but now what? are you going to use the title or title-author combo to uniquely identify each book?" When we use these Book
things, we will likely want to be able to find a particular one, in a library of hundreds or thousands. We need an id
.
And that, right there, has bugged me for a while. You see, the id
of a book (or of most objects) is fundamentally not a part of the data. The Book
doesn't care about it, it doesn't use it internally, it doesn't need to reference it. It is not intrinsic to the object itself.
But we still need it. It might not be a part of the Book
, but it helps us to identify the book. It isn't intrinsic, so it's extrinsic. By that, I mean that the id
might help us recognize a book as unique, but it is not a fundamental part of the Book
"blueprint".
A Step Closer
If we know that the id
should indicate the thing it is tagging, but it isn't a core part of that thing's functionality, how might that look? Well, we might start where we want to end:
const myBook = {
_id: 'eddc45f3-c35b-4717-940a-a92021a450b3',
data: // our Book object
}
So we can wrap our Book
, or whatever we're collecting, inside another object - and that one simply contains two properties: _id
and data
. I'm using crypto.randomUUID()
or a shim here to generate that _id
value, but whatever makes sense.
Doesn't look like much, but it's a bit of functionality we will see often. If we store a value to Google's Firestore, it will come back looking very similar to this - it is just a container for our data, with a unique key attached.
Yeah, But How Does That Help?
Let's revisit the Collection
, and keep this wrapping-id thing in mind as we do.
// helper function to generate random UUID
const createUUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}
const Collection = (title='Default Collection', _id=createUUID()) => {
let stuff = [];
const add = (item, _id=createUUID() )=> {
const thing = Object.freeze({_id, data: item})
stuff = [...stuff, thing];
}
const remove = (id) => {
stuff = stuff.filter(thing => thing._id != id);
}
const findById = (id) => stuff.find(thing=>thing._id === id)
const find = (func) => stuff.filter(({data})=>func(data) );
const findAll = () => [...stuff];
const update = (id, updateFunc) => {
stuff = stuff.map(
(thing) => thing._id===id ?
Object.freeze({_id, data: updateFunc(thing.data)}) :
thing
);
return findById(id);
}
return {
get _id(){ return _id; },
get title(){ return title;},
add,
remove,
find,
findAll,
update
}
}
This took a little more thinking: rather than dealing with our objects directly, we now have an added layer of object in there. So when we pass in a find
function, we want to find on the passed in thing, not on our container. When we update
, we want to leave the container alone - the user can consume the data
, but shouldn't be able to alter our _id
.
Also note that, rather than rely on the system's crypto.randomUUID()
, I'm using a pretty common createUUID()
. Does much the same thing, and won't be facing the security limitations that crypto
might.
Yeah, But Why?
A good question, "what's the point?" Let's see what that gets us:
const myDictionary = Collection(`Toby's Private Word Bank`);
myDictionary.add({term: "Cabbage", definition: "n. A familiar kitchen-garden vegetable about as large and wise as a man's head."});
myDictionary.add({term: "Cat", definition: "n. A soft, indestructible automaton provided by nature to be kicked when things go wrong in the domestic circle."})
myDictionary.add({term: "Famous", definition: "adj. Conspicuously miserable."})
myDictionary.add({term: "History", definition: "n. An account mostly false, of events mostly unimportant, which are brought about by rulers mostly knaves, and soldiers mostly fools."});
myDictionary.add({term: "Resident", definition: "adj. Unable to leave."});
So we can add things to it. And as we added each one, we were returned a value that we could have caught: the original object in its new wrapper. That could be handy, if we wanted to report its addition or wanted to add something to, for example, the DOM.
Let's see if we can find stuff:
console.log(
myDictionary.find(
(el)=>el.definition.startsWith('adj')
)
)
// [
// {
// _id: '6a9fd02e-10b9-4e96-a454-50a4b53839ff',
// data: { term: 'Famous', definition: 'adj. Conspicuously miserable.' }
// },
// {
// _id: 'ed277d50-f9f7-4c5f-888c-67dbd09ca416',
// data: { term: 'Resident', definition: 'adj. Unable to leave.' }
// }
//]
And again, when we find the two that match (Famous
and Resident
), we are getting the item itself, and its container.
We can place anything in that collection, and each placed thing will receive a unique _id
we can use elsewhere. Build a collection of words, each word will be tagged. Build a collection of books, each book will be tagged. And so on.
One More Possibility...
Suppose we want to take this, and build a Project Manager type thing. Basically, a todo list manager app. Well, each todo list is just a Collection
, so we could:
const todoLists = [
Collection("Personal Tasks"),
Collection("Work: Group Projects"),
Collection("Work: Personal Goals"),
Collection("Family Chores")
]
And we have any number of Todo lists, just like that. Each will let us tag our Todos as we add them, giving them a unique id for us.
But that array? It's also a collection:
const todoAppManager = Collection('My Todo App Manager')
todoLists.forEach( todoList =>{
// each created TodoList has its own unique id already. Let's
// leverage that!
todoAppManager.add(todoList, todoList._id)
})
So we have a collection... of collections!
Next Steps
In the next one, I'd like to look at how we might make this handle callbacks. It could be useful, when an item is added to a collection, to fire off an observer, handling activities outside of the collection (saving the collection to localStorage
or a remote database, updating the DOM, popping up a flyout notification... whatever we might like).
Top comments (0)