Free Code Camp's Record Collection challenge seems to be the point in their curriculum at which JavaScript gets hard for new learners. It is the first relatively complex challenge, the first that isn't a simple exploration of syntax. It is also the first challenge that simulates common semi-complex functionality of the kind that would be found in real-world applications, so understanding it is important.
This article is long and ends with a possible solution, followed by refactoring: the focus is how and why. It goes over a lot of ground already covered on FCC in previous challenges.
Objects are the only complex data type found in JavaScript, and as such, understanding how to deal with them is central to understanding JavaScript.
The challenge covers:
- Checking object properties exist.
- Creating, updating and deleting properties.
- Working with nested objects and arrays.
These are very basic features, but most time spent working with objects is likely to involve doing simple manipulations, so being able to so confidently makes every further step simpler.
An object is a collection of properties; each property is a key/value pair. So it is like a dictionary: the key is the name of the property, and the value is the definition.
Keys in Javascript are represented as strings.
var myObj = { "key": "value" }So myObj has a property called "key" which has a value of "value".
To access that value, you tell JS which key you want, and it will tell you the value:
> myObj["key"]
"value"If the key is a valid identifier, you can:
-
Leave off the quotation marks in the definition:
myObj = { key: "value" }.var myObj = { key: "value" } -
Access using dot notation rather than using the square brackets, making it easier to read.
> myObj.key "value"
In practice, what this generally means is:
-
if you have a key that starts with a number, or has a space in, you need to use quotation marks and brackets:
myObj = { "1234": "foo", "key with spaces": "bar", }
-
To access a key, you have to use brackets:
> myObj["1234"] "value" > myObj["key with spaces"] "bar"
If you have a variable, and you want to use it when checking an object, again, you have to use brackets:
var myKey = "imAKey";
var myObj = {
imAKey: "Hello"
}If you tried to use dot notation here - myObj.myKey - JS would try to find
a key called "myKey" in myObj. Instead:
> myObj[myKey]
"Hello"Note that arrays are a special kind of object, and in arrays, each key is a number.
These two are basically the same (NOTE that arrays are what's
called exotic objects, which have some extra rules - ie the keys are
automatically created starting from "0", and they have a length property
that automatically updates when things are added or removed).
var myArrayOne = [1,2,3]
var myArrayTwo = {
"0": 1,
"1": 2,
"2": 3,
}> myArrayOne[0] // JS is converting this to a string automatically
1
> myArrayTwo[0]
1
> myArrayOne["0"]
1
> myArrayTwo["0"]
1And if you try to use dot notation, it won't work - a number is not a valid identifier
> myArrayOne.0
SyntaxError: Unexpected number
> myArr.2
SyntaxError: Unexpected numberLet's assume an object has been defined. One very common operation is to check whether that object has a certain property.
var myObj = {
foo: 1,
bar: 2,
baz: 3,
}There are two generally-used ways to check whether a property exists. Firstly,
in:
> "foo" in myObj
true
> "quux" in myObj
falseThis is simple, and works in many use cases. However, it checks all the properties, including those defined natively, by JS. For example, all objects have a method "toString()" and a method "hasOwnProperty()".
> "toString" in myObj
true
> "hasOwnProperty" in myObj
trueMuch of the time, this isn't an issue, but there is a slightly more robust
way to check: the hasOwnProperty() method. This checks if the current
object has a property:
> myObj.hasOwnProperty("foo")
true
> myObj.hasOwnProperty("hasOwnProperty")
falseThe syntax in JS for creating/updating/deleting properties is identical regardless of whether they exist on the object or not.
No error will be thrown if attempting to delete a property that doesn't exist, setting the value of a property that doesn't exist will create that property, setting the value of a property that does exists will just change the value.
So with our object:
var myObj = {
foo: 1,
bar: 2,
baz: 3,
}Some operations can be performed on it:
> delete myObj.baz
true
> myObj
{ foo: 1, bar: 2 }
> delete myObj.quux
true
> myObj
{ foo: 1, bar: 2 }
> myObj.foo = 2
> myObj
{ foo: 2, bar: 2 }
> myObj.baz = 2
> myObj
{ foo: 2, bar: 2, baz: 2 }
> myObj["1234"] = "Some text"
> myObj
{ foo: 2, bar: 2, baz: 2, "1234": "Some text" }
> delete myObj["1234"]
> myObj
{ foo: 2, bar: 2, baz: 2 }
> var imAProp = "baz"
> delete myObj[imAProp]
> myObj
{ foo: 2, bar: 2 }You are given a JSON object representing a part of your musical album collection. Each album has several properties and a unique id number as its key. Not all albums have complete information.
So you have an object representing albums, and you can guarantee each album in the object has an ID that you can use to look it up. The actual album object itself has 0 or more properties.
var collection = {
"2548": {
"album": "Slippery When Wet",
"artist": "Bon Jovi",
"tracks": [
"Let It Rock",
"You Give Love a Bad Name"
]
},
"2468": {
"album": "1999",
"artist": "Prince",
"tracks": [
"1999",
"Little Red Corvette"
]
},
"1245": {
"artist": "Robert Palmer",
"tracks": [ ]
},
"5439": {
"album": "ABBA Gold"
}
};Write a function which takes an album's id (like 2548), a property prop (like "artist" or "tracks"), and a value (like "Addicted to Love") to modify the data in this collection. Your function must always return the entire collection object.
function updateRecords(id, prop, value) {
return collection;
}NOTE From hereon in, I am working with a modified version of the actual
function signature used in the FCC challenge. As it stands, the function takes
three arguments (id, prop, value), and collection, a global variable,
is used inside the function. This I would consider extremely bad practice:
the collection variable comes from outside the function, so the function
cannot work on its own. It is dependent on something it should not be dependent
on, hence I am adding collection as the first variable:
function updateRecords(collection, id, prop, value) {
return collection;
}It maybe could be assumed that if an id is passed, the album will be there in the collection, but it would be good to have a check in place to stop the collection being modified if the wrong thing is passed in.
So, instead of going straight into the logic like:
function updateRecords(collection, id, prop, value) {
// logic to modify collection[id] here
return collection;
}Put in a check, so the collection cannot be modified when incorrect input is passed:
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
// logic to modify collection[id] here
}
return collection
}The logic to modify the collection can now be added. There are four rules:
- If prop isn't "tracks" and value isn't empty (""), update or set the value for that record album's property.
- If prop is "tracks" but the album doesn't have a "tracks" property, create an empty array before adding the new value to the album's corresponding property.
- If prop is "tracks" and value isn't empty (""), push the value onto the end of the album's existing tracks array.
- If value is empty (""), delete the given prop property from the album.
In pseudocode:
# The album being operated on is collection[id].
# Assuming the collection has a property of id (eg, it has the album):
updateRecords(collection id, prop, value) =
if collection has id:
# If prop isn't "tracks" and value isn't empty (""), update or set the value
# for that record album's property.
if prop is not "tracks" and value is not "":
album[prop] = value
# If prop is "tracks" but the album doesn't have a "tracks" property, create an
# empty array before adding the new value to the album's corresponding property.
if prop is "tracks" and album does not have prop "tracks" and value is not "":
album["tracks"] = new array, push value
# If prop is "tracks" and value isn't empty (""), push the value onto the end
# of the album's existing tracks array.
if prop is "tracks" and album has prop "tracks" and value is not "":
album["tracks"], push value
# If value is empty (""), delete the given prop property from the album.
if value is "":
delete album[prop]
always return collection
So, converting the pseudocode to JS:
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
if (props !== "tracks" && value !== "") {
collection[id][prop] = value;
} else if (prop === "tracks" && collection[id].hasOwnProperty("tracks") === false && value !== "") {
collection[id][prop] = [value];
} else if (prop === "tracks" && collection[id].hasOwnProperty("tracks") && value !== "") {
collection[id][prop].push(value);
} else if (value === "") {
delete collection[id][prop];
}
}
return collection;
}These can be done in any order, but to keep the code readable and the developer sane, simplifying those conditions as much as is possible is preferable.
Shifting them round helps. The conditions run in order - if one returns false,
the next one is checked, and so on. The first three conditions check that the
value is not "". If the value is an empty string, that prop is deleted
regardless of what the prop is. So it would make sense to move the deletion
to the top and take those conditions out:
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
if (value === "") {
delete collection[id][prop];
} else if (props !== "tracks") {
collection[id][prop] = value;
} else if (prop === "tracks" && collection[id].hasOwnProperty("tracks") === false) {
collection[id][prop] = [value];
} else if (prop === "tracks" && collection[id].hasOwnProperty("tracks")) {
collection[id][prop].push(value);
}
}
return collection;
}The two conditions dealing with "tracks" are very similar - these can be merged, so that each condition now has only one check:
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
if (value === "") {
delete collection[id][prop];
} else if (props !== "tracks") {
collection[id][prop] = value;
} else if (prop === "tracks") {
if(collection[id].hasOwnProperty("tracks")) {
collection[id][prop].push(value);
} else {
collection[id][prop] = [value];
}
}
}
return collection;
}Now, objects assigned to variables are referenced, rather than attached directly.
If you have a variable like var str = "String", str points directly at
"String". But with objects, this is not the case. Why is this relevant? Well,
if I have an object like this:
var myObj = {
foo: 1,
bar: 2,
baz: { quux: 3 },
}If I assign a variable like this:
var baz = myObj.bazThis is pointing at the nested object inside myObj. If I modify my variable
baz, I will modify the original object. Like so:
> baz.quux = 100
> myObj
{ foo: 1, bar: 2, baz: { quux: 100 }}With this in mind, I can make the function a little more readable:
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
var album = collection[id];
if (value === "") {
delete album[prop];
} else if (props !== "tracks") {
album[prop] = value;
} else if (prop === "tracks") {
if(album.hasOwnProperty("tracks")) {
album[prop].push(value);
} else {
album[prop] = [value];
}
}
}
return collection;
}The "tracks" property functionality is very similar as well: it might be nice to flatten out the conditional statements a bit. We're three levels deep at that point, and it might be good to drop that to two by moving the functionality out:
function setArrayProperty(object, prop, value) {
if (object.hasOwnProperty(prop) && Array.isArray(object[prop])) {
object[prop].push(value)
} else {
object[prop] = [value]
}
}
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
var album = collection[id];
if (value === "") {
delete album[prop];
} else if (prop !== "tracks") {
album[prop] = value;
} else if (prop === "tracks") {
setArrayProperty(album, prop, value);
}
}
return collection;
}NOTE: the tasts will fail with the object above. The solution should not have
collectionas the first argument, instead use:
function setArrayProperty(object, prop, value) {
if (object.hasOwnProperty(prop) && Array.isArray(object[prop])) {
object[prop].push(value)
} else {
object[prop] = [value]
}
}
function updateRecords(collection, id, prop, value) {
if (collection.hasOwnProperty(id)) {
var album = collection[id];
if (value === "") {
delete album[prop];
} else if (prop !== "tracks") {
album[prop] = value;
} else if (prop === "tracks") {
setArrayProperty(album, prop, value);
}
}
return collection;
}
Thanks!