Part II: JavaScript Types in Depth
Following the short introduction on JavaScript, we will now move on and explore the different JavaScript types in more detail.
Using Strings in JavaScript
In software development, working with strings is a common problem. We often read, process, and write text files, perform logging on the activities of a system, or analyze user input.
Learning how to perform string operations is essential in any programming language.
Let’s first recap what you already know about strings from the JavaScript fundamentals article.
Creating JavaScript strings
Strings contain a sequence of characters. You can either use single quotes (e.g. 'ES6 in Practice') or double quotes (e.g. "ES6 in Practice") to create strings.
1 let firstString = "Hugo's dog",
2 secondString = 'Hugo\'s dog';
In the console, strings are displayed with double quotes.
As you can see in the previous example, it is possible to use a single quote inside a double quote, and it is also possible to use a double code inside a single quote. If you want to use a single quote inside a single quote, you have to escape it with a backslash.
The \ tells the JavaScript interpreter that the next character should be treated as a literal character, not as a meta character. Therefore, \' does not signal the start or the end of a string. Side note: if you want to place a backslash in a string, you have to escape it in the form of \\.
Equality of strings
Two strings are equal whenever their values are equal:
1 > firstString == secondString
2 true
3
4 > firstString === secondString
5 true
6
7 > firstString === 'Hugo\'s dog'
8 true
Sorting strings
Often times in software development, we have to sort strings. There are a few problems with string sorting:
- upper case and lower case letters are sorted differently:
'a' > 'B', - accented characters are completely out of sequence:
'á' > 'b'.
The localCompare string method solves both problems:
1 > 'á'.localeCompare( 'b' )
2 -1
3 > 'á'.localeCompare( 'a' )
4 1
5
6 > 'a'.localeCompare( 'A' )
7 -1
8 > 'b'.localeCompare( 'A' )
9 1
10 > 'A'.localeCompare( 'b' )
11 -1
12 > 'B'.localeCompare( 'b' )
13 1
Sorting an array of strings in place works as follows:
1 const words = [ 'Practice', 'ES6', 'in', 'á' ];
2 const sorter = function( a, b ) {
3 return a.localeCompare( b );
4 }
5
6 words.sort( sorter );
In the console, you can look up words:
1 > words
2 ["á", "ES6", "in", "Practice"]
This sort method of arrays expects a helper function such as sorter. This helper function expects two arguments, a and b. The helper function should be written in such a way that it should return a positive value whenever a > b, a negative value whenever a < b, and zero if a and b are equal.
The sort JavaScript array method sorts its contents in place. This means the order of the elements change inside the array.
The length of a string
Strings have a length property that is equal to the number of characters in the string. The shortest possible string is the empty string having a length of 0:
1 > firstString.length
2 10
3
4 > ''.length
5 0
Multiline strings
When defining the string, the start and end of the string has to be in the same line. Therefore, we need to insert a newline character into the string to make it multiline:
1 'First line.\nSecond line.\nThird line.'
Alternatively, you can place a backslash at the end of the line:
1 'First line.\
2 Second line.\
3 Third line.'
Both solutions are inconvenient. Fortunately, in ES6, template literals were introduced.
Template literals
Template literals and template tag functions are considered an advanced topic, which is out of scope for us at this stage. Remember, the objective of this article is to highlight practical use cases of strings.
A template literal is defined using backticks:
1 const htmlTemplate = `
2 <div>
3 This is a node template.
4 </div>
5 `;
Notice that newline characters stay inside the template literal.
It is also possible to evaluate JavaScript expressions inside a template literal:
1 const nodeText = 'This is a node template';
2 const parametrizedHtmlTemplate = `
3 <div>
4 ${ nodeText }
5 </div>
6 `;
Once created, template literals are immediately evaluated and converted to strings:
1 > parametrizedHtmlTemplate
2 "
3 <div>
4 This is a node template
5 </div>
6 "
Note that in the developer tools console, instead of the newlines, you may see the symbol written on your ENTER key to denote the actual line breaks.
The in-depth description of template literals is in my book ES6 in Practice, and you can also read the theory in this article on template literals.
Trimming strings
Trimming is a process of removing whitespace characters before the first non-whitespace character, and after the last non-whitespace character of the string. We often remove these unwanted whitespace characters when processing templates or processing user input. For instance, assuming that _ denotes a space, instead of entering _Zsolt in a form field, you would expect that Zsolt is saved in the database.
The trim string method performs trimming:
1 const template = `
2 <div>First line</div>
3 <div>Second line</div>
4 `;
Console:
1 > template
2 "
3 <div>First line</div>
4 <div>Second line</div>
5 "
6
7 > template.trim()
8 "<div>First line</div>
9 <div>Second line</div>"
Accessing characters inside a string
The bracket notation, also used with arrays, can provide access to an arbitrary character in a string:
1 > let digits = '0123456789';
2 > digits[4]
3 "4"
Instead of indexing, you can also use the charAt method:
1 > digits.charAt(4)
2 "4"
Opposed to arrays, setting a character inside the string to a new value doesn’t work. Indexing a string is strictly read-only:
1 > digits[4] = 'X';
2 > digits
3 "0123456789"
Setting digits[4] to 'X' failed silently.
In general, strings are said to be immutable. This means that we cannot change their content. When adding a character to the end of the string, a new string is created.
To iterate on a string, all JavaScript control structures can be used: for, while, do...while, for...in, for...of:
1 let sum1 = 0;
2 for ( let i = 0; i < digits.length; ++i ) {
3 sum1 += digits[i];
4 }
5
6 let sum2 = 0;
7 for ( let i in digits ) {
8 sum2 += digits[i];
9 }
10
11 let sum3 = 0;
12 for ( let digit of digits ) {
13 sum3 += digit;
14 }
Finding the index of a substring inside a string
The indexOf and the lastIndexOf string methods return the first and last index of a substring inside a string.
1 > let sequence = '1,2,3,4,5';
2
3 > sequence.indexOf( ',' )
4 1
5
6 > sequence.lastIndexOf( ',' )
7 7
8
9 > sequence.indexOf( ',3' )
10 3
11
12 > sequence[3]
13 ","
When the argument of indexOf is a string of length higher than 1, the return value is the position of the first character.
At this point, we assume that you don’t use long unicode characters. As soon as you know you will use these characters, check out my article Strings and Template Literals in ES6 to know how to deal with them.
When string s does not contain a specific substring s0, then s.indexOf( s0 ) returns -1:
1 > sequence.indexOf( 'abc' )
2 -1
Assuming you want to enumerate the indices of all matches, you can specify a second argument, indicating the first index of the string from where we start searching:
1 > sequence.indexOf( ',' )
2 1
3
4 > sequence.indexOf( ',', 2 )
5 3
6
7 > sequence.indexOf( ',', 4 )
8 5
9
10 > sequence.indexOf( ',', 6 )
11 5
12
13 > sequence.indexOf( ',', 8 )
14 -1
The question “Does string s include the substring s0?” is commonly asked during programming problems. We could use indexOf to implement the answer:
1 s.indexOf( s0 ) >= 0
As this line of code is not too intuitive to read, in ES6, we can use the includes method for the same purpose:
1 s.includes( s0 )
For more details on the ES6 construct, check out my book ES6 in Practice or this article.
Splitting, slicing, joining, and concatenating strings
Strings have properties. One example is the length property. There are also some operations defined on strings. These operations are called methods.
We learned in the previous section that digits[4] = 'X' does not set a character inside the digits string.
By the end of this section, we will know everything needed to change a character inside strings.
Splitting a string into an array of substrings
The split method splits a string into an array of substrings. Split expects one argument describing how the split should be made.
For instance, in the programming world, we often process CSV files. CSV stands for Comma Separated Values. Let’s create an array from a CSV line:
1 > const line = '19,65,9,17,4,1,2,6';
2
3 > line.split( ',' );
4 ["19", "65", "9", "17", "4", "1", "2", "6"]
If we want to create an array containing each character in the string, we can pass an empty string to the split method:
1 > lines.split( '' );
2 ["1", "9", ",", "6", "5", ",", "9", ",", "1", "7", ",", "4", ",", "1", ",", "2", ","\
3 , "6"]
As the contents of arrays can be changed, we can easily change the digit 4 inside the digits sequence:
1 > let digitsArray = '0123456789'.split( '' );
2 > digitsArray[4] = 'X';
3 > digitsArray
4 ["0", "1", "2", "3", "X", "5", "6", "7", "8", "9"]
Split also works with a regular expression argument. In this case, the regex describes the pattern used for splitting strings. For instance, /\D/ matches every character that is not a digit. Splitting based on this regular expression works as follows:
1 > '0,1,2,3,4,5,6,7,8,9'.split( /\D/ );
2 ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
If you want to learn more about regular expressions, check out my JavaScript Regex Udemy video course. Use the coupon JSREGEX to get it at minimum price.
Joining strings
We can join strings in two ways:
- using the
+operator, - using the
joinstring method.
You already saw how the + operator works on strings in the introductory article:
1 'first part' + ' second part'
2 "first part second part"
The ‘join’ method is a bit more interesting. It takes an array of strings and joins them into one string. When join is used without any arguments, it joins the strings by inserting commas in-between them. This is because the most common string writing functionality is logging and creating CSV files.
1 > ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].join()
2 "0,1,2,3,4,5,6,7,8,9"
If you want to join strings without anything in-between them, specify an empty string as the argument of join:
1 > ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].join( '' )
2 "0123456789"
You can also specify any other characters:
1 > ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].join( '��' )
2 "0��1��2��3��4��5��6��7��8��9"
Slicing a string
The slice method is commonly used in simple functions and coding challenges. It returns a substring of the string specified by its argument list.
The easiest way to remember the parameter list of slice is:
1 str.slice( firstIndexInString )
2
3 // or
4
5 str.slice( firstIndexInString, firstIndexAfterString )
The first argument of slice specifies the position of the first character of the substring. The second argument is optional. When it is missing, slicing happens until the end of the string. When it is specified, it points at the first character after the sliced substring.
1 > let hexadecimalDigits = '0123456789ABCDEF';
2
3 > hexadecimalDigits.slice( 1, 6 )
4 "12345"
5
6 > hexadecimalDigits.slice( 10 )
7 "ABCDEF"
8
9 > hexadecimalDigits.slice( 0, 10 )
10 "0123456789"
Similarly to Python arrays, the arguments of slice can be negative. Negative values count from the end of the array:
1 > hexadecimalDigits.slice( -6 )
2 "ABCDEF"
3
4 > hexadecimalDigits.slice( -6, -3 )
5 "ABC"
6
7 > hexadecimalDigits.slice( -6, 13 )
8 "ABC"
You could learn the substr and substring methods that perform slicing using a different syntax. However, you will end up using slice most of the time anyway, therefore, in this summary, we will omit these two methods. Substr works like slice, but it allows the first argument to be greater than the second one, and still return the substring between the indices. The substring method specifies the index of the first character and the length of the substring.
The reason why I advise only using slice is that you will use the same method with arrays too. Its parametrization is intuitive, and you can also understand Python array slicing better.
Replacing characters of a string
The replace string method returns a new string, where the first substring specified by its first argument is replaced with its second argument:
1 > const numbers = '1 2 3 4';
2
3 > numbers.replace( ' ', ',' );
4 "1,2 3 4"
Notice that only the first space was replaced. If you want to replace all spaces inside a string, you can use the split and join methods:
1 > numbers.split( ' ' ).join( ',' )
2 "1,2,3,4"
Alternatively, you can also specify a regular expression as the first argument of the replace method, and apply a global flag on it to replace all matches.
1 > numbers.replace( / /g, ',' )
2 "1,2,3,4"
This solution is a bit advanced. Head over to my article Regular Expressions in JavaScript if you want to learn more.
You can replace any number of characters including zero. In case of replacing the empty string, the second argument is inserted before the first character:
1 > 'help'.replace( '', '--' )
2 "--help"
3
4 > 'help'.replace( new RegExp( '', 'g' ), '--' )
5 "--h--e--l--p--"
6
7 > '1 2 3 4'.replace( '2 3', 'five' )
8 "1 five 4"
Upper and lower case letters
The toUpperCase and toLowerCase string methods return an upper case and a lower case version of their string respectively:
1 > 'aBCd'.toLowerCase()
2 "abcd"
3
4 > 'aBCd'.toUpperCase()
5 "ABCD"
Using Arrays in JavaScript
You have learned the basics of JavaScript arrays in the introductory section. We will now work on extending your knowledge by learning more features of arrays. These features are described as array methods. We will group these array features by use case:
- creating and deleting arrays
- checking if a variable contains an array
- convert an array to a string: the
toStringmethod, - find the location of an element of an array: the
indexOf,lastIndexOf, andcontainsmethods, - the
splitstring method and thejoinarray method, - slicing: the
sliceandsplicearray methods, -
map,reduce,filter,find,findIndex, -
everyandsome, - adding and removing single elements:
push,pop,shift,unshift - concatenating arrays:
concat, - sorting arrays:
sort, - reversing arrays:
reverse.
Creating arrays
The new operator can be used to create arrays. We can specify the length of the array that is to be created:
1 const A = new Array(5);
2 // [empty × 5]
3
4 A[0]
5 // undefined
6
7 A[999]
8 // undefined
The values of the newly created array are written as empty. When accessing an empty element, or accessing an element outside the boundaries of the array, we get undefined.
The need may arise to fill all values of an array with values. The fill array method does the trick:
1 A.fill( 'a' )
2 // ["a", "a", "a", "a", "a"]
The length of the array can be modified at any time. The fill method takes the length into consideration:
1 A.length = 3
2 A.fill( 'b' )
3 // ["b", "b", "b"]
If you want to fill your array with different values that depend on the index of the array, you can use the map method. You will learn about the map method soon. Until then, let me reveal one example, foreshadowing the capabilities of map.
Example: the correct syntax of creating an array containing 1000 numbers from 1 to 1000 is as follows:
1 const oneToThousand =
2 new Array( 1000 )
3 .fill( null )
4 .map( (element, index) => index+1 );
Explanation:
- we create an array of length 1000.
- we have to fill this array with any value to indicate that these values can be accessed and transformed
- we take each element of the array, and replace it with their index, incremented by 1.
The toString method
The toString method applied on an array works as follows:
-
toStringtransfers each element of the array to a string value, - the stringified values are joined by a comma.
Examples:
1 > [1, 2].toString()
2 "1,2"
3
4 > [undefined, null, {}, [], 1, '', 's', NaN].toString()
5 ",,[object Object],,1,,s,NaN"
The values underfined, null, [], '' become empty strings. The NaN value becomes "NaN".
Checking if a variable contains an array
Before ES2015, this was a hard exercise, because arrays have the type "object", and the array check had to look for typical properties and operations of arrays. These checks were never 100% reliable.
Since ES2015, the Array.isArray method can be used:
1 const a = [];
2 const b = { 0: 'a', length: 1 };
3
4 Array.isArray( a ); // true
5 Array.isArray( b ); // false
Splitting and joining
A string can be split into an array of substrings using the split method. The argument of split determines how splitting is performed:
1 const values = 'first,second,third';
2
3 values.split( ',' )
4 (3) ["first", "second", "third"]
The join method joins values together to form one string, and inserts a spearator in-between them:
1 ["first", "second", "third"].join( ';' )
2 // returns "first;second;third"
3
4 [1, 2, 3, 4, 5].join( '---*---' )
5 // returns "1---*---2---*---3---*---4---*---5"
6
7 [1, 2, 3, 4, 5].join( ',' )
8 // returns "1,2,3,4,5"
9
10 [1, 2, 3, 4, 5].join()
11 // returns "1,2,3,4,5"
In the absence of an argument, join uses a comma to join the values of its array.
The slice method
The slice method copies a slice of an array. The slice is specified by its arguments. Both arguments of slice are optional:
1 A.slice( begin, end )
2
3 // begin: the first index to be sliced
4 // end: the first index not to be sliced
Let’s see some examples:
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 arr.slice( 1, 3 );
4 // returns: ["b", "c"] (indices 1 and 2)
5
6 arr.slice( 1 );
7 // returns: ["b", "c", "d", "e"] (starting at index 1)
8
9 arr.slice( 5 );
10 // returns: [] (there are no elements at or above index 5)
11
12 arr.slice( 2, 2 );
13 // returns: [] (the second argument is not greater than the
14 // first, which implies an empty slice)
15
16 arr.slice();
17 // returns: ["a", "b", "c", "d", "e"]
You may ask why slice can accept zero arguments. The answer lies in the nature of shallow cloning:
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 const shallowClone = arr.slice();
4
5 shallowClone[1] = 'X';
6
7 console.log( shallowClone );
8 // returns: ["a", "X", "c", "d", "e"]
9
10 console.log( arr );
11 // returns: ["a", "b", "c", "d", "e"]
Shallow cloning creates a new array with different elements that have the same values.
During my tech interviews, I sometimes see the following erroneous solution:
1 const A = ['a', 'b', 'c', 'd', 'e'];
2
3 const B = A;
4
5 B[1] = 'X';
6
7 console.log( A );
8 // returns: ["a", "X", "c", "d", "e"]
Remember, A and B are references to the exact same array. You can reach the same values both from A and from B. Instead of B = A, some of my candidates should have written B = A.slice().
You may ask the question, why can’t we just call the shallow copying operation cloning or copying? The answer lies in how reference types are treated. Arrays and objects contain references. During shallow cloning, only these references are copied:
1 const A = [{value: 1}, {value: 2}];
2 const B = A.slice();
3 B[0] = {value: 'X'};
4 B[1].value = 'Y';
5
6 console.log( B )
7 // returns: [{value: 'X'}, {value: 'Y'}]
8
9 console.log( A )
10 // returns: [{value: '1'}, {value: 'Y'}]
During cloning, we would expect to keep the contents of A intact. However, slice only performs shallow cloning, which is cloning on top level only. Therefore, B was constructed with new references that were not in A, but these references point at the same objects {value: 1} and {value: 2}. As long as you redirect the references to new objects such as {value: 'X'}, there are no problems with shallow cloning. However, as soon as you want to access the contents inside these references, you can see that the actual object content is shared between A and B.
I wrote a full article on shallow and deep copying here: Cloning Objects in JavaScript.
The splice method
The splice method is used to manipulate the contents of an array in place. You can add, remove, or modify elements using splice.
The splice array extension accepts a variable number of arguments:
1 A.splice( start, deleteCount, ...newItems )
-
start: the index at which the change occurs -
deleteCount: the number of elements to remove starting at indexstart. If this argument is missing, all elements starting atstartare deleted -
...newItems: a variable number of elements to be added to the array at positionstart.
Many JavaScript integrated development environments help you with the parametrization in case you forgot them.
Return value: an array containing the deleted elements.
Side-effect: the original array is modified. Elements are deleted from and/or added to the array.
Example 1: remove all elements starting at index 2:
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 arr.splice( 2 ); // returns: ["c", "d", "e"]
4
5 console.log( arr )
6 // prints: [ 'a', 'b' ]
Example 2: remove the third and the fourth element of the array:
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 arr.splice( 2, 2 ); // returns: ["c", "d"]
4
5 console.log( arr )
6 // prints: [ 'a', 'b', 'e' ]
Example 3: insert the elements 'X' and 'Y' after the third element of the array:
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 arr.splice( 3, 0, 'X', 'Y' ); // returns: []
4
5 console.log( arr )
6 // prints: ["a", "b", "c", "X", "Y", "d", "e"]
Example 4: replace the second, third, and fourth elements of the array with 'B', 'C', 'D':
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 arr.splice( 1, 3, 'B', 'C', 'D' ); // returns: ["b", "c", "d"]
4
5 console.log( arr )
6 // prints: ["a", "B", "C", "D", "e"]
Example 5: solve Examples 1-4 using the spread operator and destructuring, without mutating the original array. Create a different destructuring assignment for each solution:
1 const arr = ['a', 'b', 'c', 'd', 'e'];
2
3 // 1. remove all elements starting at index `2`
4 const [a1, b1] = arr;
5 const solution1 = [a1, b1];
6
7 // 2. remove the third and the fourth element of the array
8 const [a2, b2,,,e2] = arr;
9 const solution2 = [a2, b2, e2];
10
11 // 3. insert 'X' and 'Y' after the third element of the array
12 const [a3, b3, c3, ...rest] = arr;
13 const solution3 = [a3, b3, c3, 'X', 'Y', ...rest];
14
15 // 4. replace the second, third, and fourth elements of the
16 // array with `'B'`, `'C'`, `'D'`
17 const [a4,,,,e4] = arr;
18 const solution4 = [a4, 'B', 'C', 'D', e4];
Splice is useful when the array is large and you may have to modify elements of the array at a large index.
indexOf, lastIndexOf, contains
Sometimes we may want to find the first or the last index of an element in an array:
1 const arr = [1, 2, 3, 2, 1];
2
3 arr.indexOf( 1 ) // returns 0
4 arr.lastIndexOf( 1 ) // returns 4
You can also examine the first index of an element that is greater than or equal to the second argument passed to indexOf:
1 arr.indexOf( 1, 0 ) // reutnrs 0
2 arr.indexOf( 1, 1 ) // returns 4
Similarly, you can retrieve the last index of an element that is smaller than or equal to the second argument passed to lastIndexOf:
1 arr.lastIndexOf( 1, 4 ) // returns 4
2 arr.lastIndexOf( 1, 3 ) // returns 0
Both indexOf and lastIndexOf returns -1 if its first argument is not found in the array or array segment:
1 arr.indexOf( 1, 5 ) // returns -1
2 arr.lastIndexOf( 4 ) // returns -1
Before ES2016, a typical use case for
indexOfwas to determine if an array contains a value. Since ES2016, this operation is performed using the more semanticincludesmethod.
1 // ES2015 or earlier
2 if ( arr.indexOf( value ) !== -1 ) {
3 // do something
4 }
5
6 // since ES2016
7 if ( arr.includes( value ) ) {
8 // do something
9 }
The A.includes( value ) method returns:
-
trueifvalueis inA, -
falseotherwise.
Map, reduce, filter, find
This is an introductory section introducing some array methods that will be used later in the Higher order functions section of the functional programming chapter. The objective of this section is to define these functions.
Suppose an array arr is given with the value [1, 2, 3, 4, 5].
Map takes each element of the array, and applies a function on them one by one. For instance, suppose we have a times2 function that multiplies each element of the array by 2:
1 const arr = [1, 2, 3, 4, 5];
2
3 function times2( input ) {
4 return 2 * input;
5 }
The execution of map is as follows:
1 arr.map( times2 ) // becomes
2 [1, 2, 3, 4, 5].map( times2 ) // becomes:
3 [times2(1), times2(2), times2(3), times2(4), times2(5)] // becomes:
4 [2*1, 2*2, 2*3, 2*4, 2*5] // becomes:
5 [2, 4, 6, 8, 10]
As creating the times2 function takes a lot of lines to write, it makes more sense to simply use an arrow function:
1 const arr = [1, 2, 3, 4, 5];
2 const result = arr.map( x => 2*x ); // returns [2, 4, 6, 8, 10]
The above expression is equivalent to defining the times2 function and applying it on the array using map.
The map array extension accepts a function that may have two arguments, specifying the mapped element and its index:
1 const fruits = ['apple', 'pear', 'banana'];
2 const result = fruits.map( (item, index) => [index, item] );
3 // result becomes: [[0, 'apple'], [1, 'pear'], [2, 'banana']]
Note that the above result can also be described using the entries method:
1 const fruits = ['apple', 'pear', 'banana'];
2 const result = [...fruits.entries()];
You may ask why it is necessary to write [...fruits.entries()] instead of just fruits.entries(). The answer is, because fruits.entries() returns an array iterator that is also an iterable object. This iterable can be iterated using the spread operator, resulting in elements. The array brackets consume these values, resulting in an array. You don’t have to understand these concepts at this stage, this is advanced JavaScript. If you are interested in the details, check out my article ES6 Iterators and Generators, and check out the exercises belonging to the article.
Similarly to map, filter also returns an array, but it uses a function that returns booleans. Suppose we have a function that returns true only for even numbers. The role of filter is to keep those elements of the array for which the function passed to it returns true:
1 const arr = [1, 2, 3, 4, 5];
2 arr.filter( x => x % 2 === 0 ) // returns [2, 4]
3 arr.filter( x => x % 2 !== 0 ) // returns [1, 3, 5]
The find method is a special case for filter: it returns the first element found.
1 const arr = [1, 2, 3, 4, 5];
2 arr.find( x => x % 2 === 0 ) // returns 2
Throughout my practice as a job interviewer, I have seen countless examples of trying to break out of the forEach helper method. It is not possible, because forEach does not terminate. Suppose we have the following code printing the first even value from arr:
1 const arr = [1, 2, 3, 4, 5];
2 for ( let i = 0; i < arr.length; ++i ) {
3 if ( arr[i] % 2 === 0 ) {
4 console.log( arr[i] );
5 return;
6 }
7 }
Some applicants I interviewed were obsessed with using the forEach helper claiming that they do functional programming. In reality, the following code is not only erroneous, but it has nothing to do with functional programming:
1 // WARNING! ERRONEOUS CODE!
2 const arr = [1, 2, 3, 4, 5];
3 arr.forEach( x => {
4 if ( x % 2 === 0 ) {
5 console.log( x );
6 return; // This statement only returns from the inner function
7 }
8 });
The above code is not equivalent to the code with the for loop. This is because forEach executes its function argument on each member of the array, regardless of what this function argument returns.
My candidates were looking for the find method:
1 const arr = [1, 2, 3, 4, 5];
2 console.log( arr.find( x => x % 2 === 0 ) ); // prints 2
The function passed to find may contain a second argument, the index of the array:
1 const fruits = ['apple', 'pear', 'banana'];
2 fruits.find( (item, index) => index%2 === 0 );
3 // returns "apple"
The findIndex method works like find, except that instead of the item, it returns the index corresponding to the element found.
1 const fruits = ['apple', 'pear', 'banana'];
2
3 fruits.findIndex( (item, index) => index%2 === 0 );
4 // returns 0
5
6 fruits.findIndex( x => x === 'banana' )
7 // returns 2
Reduce creates a value from an array, applying a function on it that accumulates a value. Instead of the arrow function notation, I will rather use the more verbose form for easier understandability:
1 const arr = [1, 2, 3, 4, 5];
2
3 const reducer = function( accumulator, value ) {
4 console.log( 'accumulator: ', accumulator, 'value: ', value );
5 return accumulator * value;
6 }
If you call reduce with just the reducer function as the only argument, reduce takes the first element of the array as an accumulator value, and it starts the reduction with the second element of the array:
1 console.log( arr.reduce( reducer ) )
2 accumulator: 1 value: 2
3 accumulator: 2 value: 3
4 accumulator: 6 value: 4
5 accumulator: 24 value: 5
6 120
You can also initialize the accumulator by passing a second argument to reduce:
1 console.log( arr.reduce( reducer, 1 ) )
2 accumulator: 1 value: 1
3 accumulator: 1 value: 2
4 accumulator: 2 value: 3
5 accumulator: 6 value: 4
6 accumulator: 24 value: 5
7 120
The reduction was performed in 5 steps, not 4.
The every and some methods
Often times we may have to check if
- every element of an array satisfy a condition,
- there exists at least one element that satisfies a condition.
The every and some methods perform these checks.
Example:
1 const numbers = [3, 39, 42, 51];
2
3 const allAreEven = numbers.every( n => n % 2 === 0 );
4 // returns false
5
6 const allAreDivisibleByThree = numbers.every( n => n % 2 === 0 );
7 // returns true
8
9 const atLeastOneIsOdd = numbers.some( n => n % 2 !== 0 );
10 // returns true
Adding and removing single array elements
Suppose an array A is given:
-
A.push( ...elements )inserts...elementsto the back of the array, modifying the original array. The new length of the array is returned. -
A.pop()removes the last element from arrayAand returns this removed element. -
A.unshift( ...elements )inserts...elementsto the front of the array, modifying the original array. The new length of the array is returned. -
A.shift()removes the first element (head) from arrayAand returns this removed element.
The ...elements argument list can contain any number of arguments. The most common use case is one argument.
Example:
1 const A = ['a', 'b', 'c'];
2
3 A.push( 'X' ); // returns 4
4 console.log( A ); // prints ['a', 'b', 'c', 'X']
5
6 A.pop(); // returns 'X'
7 console.log( A ); // prints ['a', 'b', 'c']
8
9 A.unshift( 'X' ); // returns 4
10 console.log( A ); // prints ['X', 'a', 'b', 'c']
The spread operator and destructuring helps you express the same operations without the use of push, shift, and unshift.
1 // A.push( element )
2 A = [...A, element]
3
4 // A.unshift( element )
5 A = [element, ...A]
6
7 // A.shift()
8 [,...A] = A
The pop method can only be expressed with destructuring if we know the size of the array, and we denote each element with a variable:
1 // A.pop(), where A.length is 3:
2 [a, b] = A;
3 A = [a, b];
We only expressed pop for the sake of the exercise, because pop is a lot more semantic than these destructuring assignments.
Note that performance-wise push, pop are expected to be better, however, the difference is not major for small arrays.
1 console.time( 'push_pop' );
2 for ( let i = 0; i < 1000000; ++i ) {
3 let A = [1, 2, 3, 4, 5, 6, 7, 8, 9];
4 A.push( 10 );
5 A.pop();
6 }
7 console.timeEnd( 'push_pop' );
8 console.time( 'spread' );
9 for ( let i = 0; i < 1000000; ++i ) {
10 let A = [1, 2, 3, 4, 5, 6, 7, 8, 9];
11 A = [...A, 10];
12 A.pop();
13 }
14 console.timeEnd( 'spread' );
15
16 VM229:7 push_pop: 68.93310546875ms
17 VM229:14 spread: 489.0859375ms
The output is the time elapsed between executing console.time and console.timeEnd on the same label.
The shift and unshift operations may perform worse than simple destructuring for small arrays.
1 console.time( 'unshift_shift' );
2 for ( let i = 0; i < 1000000; ++i ) {
3 let A = [1, 2, 3, 4, 5, 6, 7, 8, 9];
4 A.unshift( 0 );
5 A.shift();
6 }
7 console.timeEnd( 'unshift_shift' );
8 console.time( 'spread' );
9 for ( let i = 0; i < 1000000; ++i ) {
10 let A = [1, 2, 3, 4, 5, 6, 7, 8, 9];
11 A = [0, ...A];
12 [,...A] = A;
13 }
14 console.timeEnd( 'spread' );
15 VM282:7 unshift_shift: 176.8681640625ms
16 VM282:14 spread: 145.7060546875ms
As the size of the array grows, we may end up with times shifting in favor of shift and unshift:
1 console.time( 'unshift_shift' );
2 for ( let i = 0; i < 1000000; ++i ) {
3 let A = new Array(1000).fill('a');
4 A.unshift( 0 );
5 A.shift();
6 }
7 console.timeEnd( 'unshift_shift' );
8 console.time( 'spread' );
9 for ( let i = 0; i < 1000000; ++i ) {
10 let A = new Array(1000).fill('a');
11 A = [0, ...A];
12 [,...A] = A;
13 }
14 console.timeEnd( 'spread' );
15 VM319:7 unshift_shift: 7177.94873046875ms
16 VM319:14 spread: 18837.948974609375ms
The push and unshift methods may receive a variable number of arguments:
1 const A = [4, 5, 6];
2 A.push( 7, 8, 9 ); // returns 6
3 A.unshift( 0, 1, 2, 3 ); // returns 10
4
5 console.log( A );
6 // prints [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Array concatenation and the concat method
Suppose two arrays are given:
1 const A = [1, 2, 3];
2 const B = ['apple', 'pear'];
An obvious way to concatenate two arrays is to use the spread operator:
1 const AB = [...A, ...B];
2 // [1, 2, 3, "apple", "pear"]
The concat method is a generally more efficient way to perform the same concatenation:
1 A.concat( B )
2 (5) [1, 2, 3, "apple", "pear"]
3 A
4 (3) [1, 2, 3]
5 B
6 (2) ["apple", "pear"]
As you can see, concat does not mutate the original array A.
We can supply multiple arrays in the argument list of concat:
1 const A = [1, 2];
2 A.concat( [3, 4], [5, 6], [], [7, 8] );
3
4 console.log( A );
5 // prints [1, 2, 3, 4, 5, 6, 7, 8]
Another way to concatenate arrays is to use push:
1 const A = [1, 2];
2 const B = [3, 4];
3 const C = [5, 6];
4 A.push( ...B, ...C );
5
6 console.log( A );
7 // prints [1, 2, 3, 4, 5, 6]
Notice that using push modifies the original array A.
The sort method
The sort method sorts the values in an array:
1 const arr = [2, 5, 1, 3, 4];
2 arr.sort();
3
4 console.log( arr ); // [1, 2, 3, 4, 5]
Sorting is performed in place, mutating (changing the values inside) the original array. The sort method also returns a reference to the sorted array:
1 const arr = [2, 5, 1, 3, 4];
2 arr.sort()
3 // [1, 2, 3, 4, 5]
For strings, sorting is performed lexicographically based on unicode values:
1 const words = ['Frank', 'ate', 'apples'];
2 console.log( words.sort() )
The reason why Frank is before ate is that the character code value of F is lower than the character code value of a:
1 'F'.codePointAt(0)
2 70
3 'a'.codePointAt(0)
4 97
If the first character of both strings are equal, the upcoming characters are examined. As the code of p is smaller than the code of t, the order can be determined.
Unfortunately, the default sort implementation treats numbers as strings:
1 [12, 122, 21].sort()
2 // returns: [12, 122, 21]
We know that 122 is smaller than 21, but according to their string values, 21 has to be larger than both 12 and 122 due to their first character.
Another problem comes with the treatment of accented characters that occur in many languages. For instance:
1 ['o', 'ö', 'u', 'ü'].sort()
2 // returns: ["o", "u", "ö", "ü"]
It is evident that ö should come right after o, but the character codes don’t respect this order.
We can fix all these anomalies by passing a comparator function to sort. Sorting is performed based on the return value of the comparator function comparator( a, b ):
1 const arr = [12, 122, 21];
2 const comparatorLargestFirst = (x, y) => y - x;
3 const comparatorSmallestFirst = (x, y) => x - y;
4
5 arr.sort( comparatorLargestFirst );
6 // returns: [12, 21, 122]
7
8 arr.sort( comparatorSmallestFirst );
9 // returns: [12, 21, 122]
The above comparator functions solve the problem of numerical sorting. The localeCompare string method can be used to order characters of different locales:
1 ["o", "u", "ö", "ü"].sort( (s1, s2) => s1.localeCompare( s2 ) )
2 // returns: ["o", "ö", "u", "ü"]
The reverse method
The reverse method reverses an array in place:
1 const arr = [12, 122, 21];
2
3 arr.reverse() // returns: [21, 122, 12]
4
5 console.log( arr )
6 // prints: [21, 122, 12]
Using Objects in JavaScript
We learned in the introductory chapter that objects are collections of data and operations that are related. Objects help you group any number of values using one reference.
Data fields stored inside an object are called properties.
Operations that can be executed on an object are called methods.
Let’s see an example:
1 let account = {
2 owner: 'Zsolt',
3 amount: 1000,
4 deposit: function( depositAmount ) {
5 this.amount += depositAmount;
6 }
7 }
Notice that we can have access to properties of the objects inside methods by using the this keyword.
There are two notations to retrieve the field values of an object:
- dot notation:
account.amount, - bracket notation:
account['amount'].
1 > account.owner
2 'Zsolt'
3
4 > account.deposit( 1000 )
5 undefined
6
7 > account.amount
8 2000
Both notations can be used to get and set values of objects, and delete properties of an object:
1 account.owner = null;
2
3 account['x'] = true;
4 delete account['x'];
For instance, the above expression sets account['owner'] to null. Note that the type of the new value does not have to match the type of the original value.
To remove a field from an object, use the delete operator:
1 > account
2 {owner: null, amount: 2000, deposit: ƒ}
3
4 > delete account.owner
5 true
6
7 > account
8 {amount: 2000, deposit: ƒ}
The expression delete account.owner removes the owner: null key-value pair from the object. The expression is then evaluated to true, because the deletion succeeded.
We have seen one way to create objects: the object literal notation. An empty object can be created using {}. After creation, properties and operations can be added to it:
1 const objectLiteral = {};
2 objectLiteral.property = 1;
3 objectLiteral.operation = () => 1;
Including the {} notation, there are three ways of creating an empty object:
-
{}, -
new Object(), -
Object.create( Object.prototype ).
The third option looks a bit alien to many JavaScript developers, but we will still use it, because it gives us an easy way to set the prototype of an object during creation.
We will now cover more advanced Object concepts:
- enumerating object property names and values,
- the global object,
- own properties and inherited properties,
- object properties and their settings,
- freezing and sealing objects,
- getters and setters,
- property shorthand notation,
- computed object keys,
- equality,
- mixins and shallow copies with
Object.assign, - destructuring objects,
- spreading objects,
- symbol keys.
In this section, we will not deal with the prototype chain, setting the prototype of an object, or getting the prototype of an object.
Enumerating object property names and values
We can retrieve an array of object keys, values, and entries, using the Object.keys, Object.values, Object.entries methods respectively. > indicates the prompt of the console.
1 let account = {
2 owner: 'Zsolt',
3 amount: 1000
4 }
5
6 > Object.keys( account )
7 ["owner", "amount"]
8
9 > Object.values( account )
10 ["Zsolt", 1000]
11
12 > Object.entries( account )
13 [["owner","Zsolt"],["amount",1000]]
The for...in loop may also enumerate properties:
1 > for ( let property in account ) {
2 console.log( property );
3 }
4 owner
5 amount
The global object
We have not covered inheritance yet. However, it is worth noting that it is possible to enumerate properties of objects that do not come from the object itself.
Inheritance of object properties is defined via the prototype chain. Some object properties are inherited from the prototype of our object, or the prototype of the prototype of our object, and so on.
The global Object sits at the top of the prototype chain. This object has many properties that are made accessible in any object we create.
1 let account = {
2 owner: 'Zsolt',
3 amount: 1000
4 }
5
6 > account.toString()
7 "[object Object]"
8
9 > account.constructor
10 ƒ Object() { [native code] }
11
12 > account.__proto__
13 {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ,
14 hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
All these properties come from the global object.
1 > Object.toString
2 ƒ toString() { [native code] }
3
4 > Object.constructor
5 ƒ Function() { [native code] }
6
7 > Object.__proto__
8 ƒ () { [native code] }
Many of these properties are not enumerable, this is why they do not make it to the results of for...in loops, Object.keys, and Object.entries.
Enumerability is a configuration option for JavaScript object. We will cover it soon.
At this stage, do not worry about the prototype chain of JavaScript objects. All you need to know is that it exists, and each object you create inherits properties from the global object. Later, we will learn how to set the prototype of objects.
Own properties and inherited properties
We learned that some properties of the global object are not enumerable.
This is not necessarily the case for all properties. For instance, we can define an enumerable property for the ancestor of our object in the prototype chain. This property makes it to the enumerations of any object we create:
1 const account = {
2 owner: 'Zsolt',
3 amount: 1000
4 };
5
6 Object.prototype.objectProperty = true;
7
8 for ( let property in account ) {
9 console.log( property );
10 }
11 owner
12 amount
13 objectProperty
This holds even if we create a new empty object:
1 for ( let property in {} ) {
2 console.log( property );
3 }
4 objectProperty
Let’s examine this property a bit further. When console logging a new object, initially, we cannot see anything:
1 > console.log( {} )
2 ►{}
However, when clicking the printed empty object, we can discover that it has a __proto__ member:
1 > console.log( {} )
2 ▼{}
3 ►__proto__: Object
When expanding __proto__ further, we can see a lot of methods as well as the objectProperty that was previously defined.
1 > console.log( {} )
2 ▼{}
3 ▼__proto__: Object
4 objectProperty: true
5 ►constructor: ƒ Object()
6 ►hasOwnProperty: ƒ hasOwnProperty()
7 ...
All these properties come from Object.prototype.
Interestingly enough, in the enumeration, the second inherited method is hasOwnProperty. This is a perfect segway towards one way to filter out inherited properties:
1 for ( let property in account ) {
2 console.log( property );
3 }
4 owner
5 amount
6 objectProperty
7
8 for ( let property in account ) {
9 if ( account.hasOwnProperty( property ) ) {
10 console.log( property );
11 }
12 }
13 owner
14 amount
Object.keys and Object.entries automatically performs the hasOwnProperty check, and only displays own properties:
1 > Object.keys( account )
2 ["owner", "amount"]
3
4 > Object.entries( account )
5 [["owner","Zsolt"],["amount",1000]]
Object.getOwnPropertyNames gives you access to the names of the own properties of an object as well:
1 > Object.getOwnPropertyNames( account )
2 ["owner", "amount"]
The difference between Object.keys and Object.getOwnPropertyNames is that the former only returns enumerable own property names, while the latter returns all own property names:
1 Object.keys( Object.prototype )
2 ["objectProperty"]
3
4 Object.getOwnPropertyNames( Object.prototype )
5 (13) ["constructor", "__defineGetter__", "__defineSetter__",
6 "hasOwnProperty", "__lookupGetter__", "__lookupSetter__",
7 "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
8 "__proto__", "toLocaleString", "objectProperty"]
It is also possible to access the configuration settings of an object using Object.getOwnPropertyDescriptors. This leads us to the next section.
Object property settings
All object properties have settings:
-
configurable: determines if the property behavior can be redefined. This means, we can make an object non-configurable, non-writable, or non-enumerable only ifconfigurableis set totrue. Only configurable properties can be removed with thedeleteoperator. -
enumerable: determines if the property will show up in an enumeration (such asfor...inloops andObject.keys) -
value: returns the value of the property -
writable: determines if the value of a property can be changed by assigning it a new value
We can access these configuration settings using Object.getOwnPropertyDescriptors.
1 > Object.getOwnPropertyDescriptors( {a: 1} );
2 {
3 a: {
4 value:1,
5 writable:true,
6 enumerable:true,
7 configurable:true
8 }
9 }
It is also possible to access the property descriptor belonging to a single property:
1 > Object.getOwnPropertyDescriptor( {a: 1 }, 'a' )
2 {value: 1, writable: true, enumerable: true, configurable: true}
3
4 > Object.getOwnPropertyDescriptor( {a: 1 }, 'b' )
5 undefined
Let’s create an empty object using the object literal.
1 const objectLiteral = {};
2 objectLiteral.property = 1;
Instead of objectLiteral.property, we can also define properties using the Object.defineProperty method. Object.defineProperty takes three arguments:
- an object reference,
- the property name,
- a configuration object including
value,configurable,enumerable, andwritable. If any of the optionsconfigurable,enumerable, orwritableare missing, their default value isfalse.
1 Object.defineProperty(
2 objectLiteral,
3 'definedProperty',
4 {
5 value: 'propertyValue',
6 configurable: true,
7 enumerable: true,
8 writable: true
9 }
10 );
Let’s experiment with different property configurations. > indicates the prompt of the console.
1 Object.defineProperty(
2 objectLiteral,
3 'nonConfigurable',
4 {
5 value: 'v',
6 configurable: false,
7 enumerable: true,
8 writable: true
9 }
10 );
11
12 > delete objectLiteral.nonConfigurable
13 false
14
15 > objectLiteral.nonConfigurable
16 "v"
17
18 > Object.defineProperty(
19 objectLiteral,
20 'nonConfigurable',
21 {
22 value: 'w',
23 configurable: true,
24 enumerable: true,
25 writable: true
26 }
27 );
28 Uncaught TypeError: Cannot redefine property: nonConfigurable
29 at Function.defineProperty (<anonymous>)
30 at <anonymous>:1:8
Non-configurable properties:
- cannot be deleted,
- cannot be reconfigured.
Let’s experiment with non-enumerable properties.
1 Object.defineProperty(
2 objectLiteral,
3 'nonEnumerable',
4 {
5 value: 'ne',
6 configurable: true,
7 enumerable: false,
8 writable: true
9 }
10 );
11
12 > for ( let p in objectLiteral ) console.log( p );
13 property
14 definedProperty
15 nonConfigurable
16
17 > Object.keys( objectLiteral )
18 ["property", "definedProperty", "nonConfigurable"]
The non-enumerable property does not show up neither in for...in loops, nor in the Object.keys enumeration.
Finally, non-writable properties don’t make it possible to change the value of a property. Assignments on the property silently fail, the user does not encounter an error, unless the code is run in strict mode declaring the string "use strict".
1 Object.defineProperty(
2 objectLiteral,
3 'nonWritable',
4 {
5 value: 'w',
6 configurable: true,
7 enumerable: true,
8 writable: false
9 }
10 );
11
12 > objectLiteral.nonWritable = 'X';
13 "X"
14
15 > objectLiteral.nonWritable;
16 "w"
This behavior is not convenient, because the assignment appears to have taken place until we query the value of the previously assigned property. Strict mode can be enabled in function scope, using an immediately invoked function expression:
1 (() => {
2 "use strict";
3 objectLiteral.nonWritable = 'X';
4 })()
5 Uncaught TypeError: Cannot assign to read only property 'nonWritable' of object '#<O\
6 bject>'
7 at <anonymous>:3:31
8 at <anonymous>:4:3
It is generally useful to use strict mode, because you tend to get more errors that can be corrected before your application is deployed on production.
Freezing and sealing objects
Based on my experience as a tech interviewer, some developers think that the const keyword can create objects with a content that cannot be modified. This statement is false. The const keyword only ensures that the variable references an object, and this reference cannot be changed. The contents of the object can be modified at any time.
1 const account = {
2 owner: 'Zsolt',
3 amount: 1000
4 }
5
6 > account.amount = 500
7 500
8
9 > account
10 {owner: "Zsolt", amount: 500}
To create a real immutable constant, the object has to be frozen:
1 const account = {
2 owner: 'Zsolt',
3 amount: 1000
4 }
5
6 Object.freeze( account );
7
8 > account.amount = 500
9 500
10
11 > account.newProperty = true
12 true
13
14 > delete account.owner
15 false
16
17 > account
18 {owner: "Zsolt", amount: 1000}
19
20 > Object.defineProperty(
21 account,
22 'newProperty2',
23 {
24 value: 'w',
25 configurable: true,
26 enumerable: true,
27 writable: false
28 }
29 );
30 Uncaught TypeError: Cannot define property newProperty2, object is not extensible
31 at Function.defineProperty (<anonymous>)
32 at <anonymous>:1:8
Summary: frozen objects are immutable. This means:
- the values of their properties cannot be changed,
- new properties cannot be added to it,
- existing properties cannot be deleted,
- the configuration of properties cannot be redefined.
Strict mode offers more errors, indicating errors when non-strict execution fails:
1 > (() => {
2 "use strict";
3 account.amount = 500;
4 })()
5 Uncaught TypeError: Cannot assign to read only property 'amount' of object '#<Object\
6 >'
7 at <anonymous>:3:20
8 at <anonymous>:4:3
9
10 > (() => {
11 "use strict";
12 account.newProperty = true;
13 })()
14 Uncaught TypeError: Cannot add property newProperty, object is not extensible
15 at <anonymous>:3:27
16 at <anonymous>:4:5
17
18 > (() => {
19 "use strict";
20 delete account.owner;
21 })()
22 Uncaught TypeError: Cannot delete property 'owner' of #<Object>
23 at <anonymous>:3:7
24 at <anonymous>:4:5
There are some use cases in JavaScript development, where we need to be able to change existing properties, without being able to add new properties to it, or remove existing properties. This is when Object.seal becomes useful.
1 const rootAccount = {
2 owner: 'root',
3 amount: Infinity
4 }
5
6 Object.seal( rootAccount );
7
8 > rootAccount.amount = 1000
9 1000
10
11 > rootAccount
12 {owner: "root", amount: 1000}
13
14 > Object.defineProperty( rootAccount, 'amount', {value: 500} )
15 {owner: "root", amount: 500}
16
17 > rootAccount
18 {owner: "root", amount: 500}
19
20 > (() => {
21 "use strict";
22 delete rootAccount.owner;
23 })()
24 Uncaught TypeError: Cannot delete property 'owner' of #<Object>
25 at <anonymous>:3:7
26 at <anonymous>:4:5
27
28 > (() => {
29 "use strict";
30 rootAccount.newProperty = true;
31 })()
32 Uncaught TypeError: Cannot add property newProperty, object is not extensible
33 at <anonymous>:3:31
34 at <anonymous>:4:5
Summary: sealed objects have properties that
- can be reassigned and reconfigured,
- cannot be deleted.
New properties cannot be added to a sealed objects.
Getters and setters
Object.defineProperty can also be used to define getters and setters for a property. Let’s define an area property such that it returns the square of the sideLength property value. When setting the area, the sideLength is automatically calculated as the square root of the area.
1 const square = {
2 sideLength: 5
3 };
4
5 Object.defineProperty(
6 square,
7 'area',
8 {
9 get: function() {
10 return this.sideLength ** 2
11 },
12 set: function( value ) {
13 this.sideLength = value ** 0.5;
14 }
15 }
16 );
17
18 > square.area
19 25
20
21 > square.area = 2
22 2
23
24 > square.sideLength
25 1.4142135623730951
There is an abbreviated format for defining getters and setters:
1 const rectangle = {
2 a: 1,
3 b: 1,
4 get area() {
5 return this.a * this.b;
6 },
7 set area( value ) {
8 this.a = this.b = value ** 0.5;
9 }
10 }
11
12 rectangle.area
13 1
14 rectangle.area = 2
15 2
16 rectangle.area
17 2.0000000000000004
18 rectangle.a
19 1.4142135623730951
20 rectangle.b
21 1.4142135623730951
Let’s investigate what happened here:
- when querying rectangle.area, we compute the product of rectangle.a and rectangle.b.
- when setting rectangle.area to 2, the line this.a = this.b = value ** 0.5; computes the values of the sides, setting them to an approximate value of the square root of 2.
- when querying the new value of rectangle.area, we get the computed value of 1.4142135623730951 * 1.4142135623730951, as the side values are both 1.4142135623730951.
Property Shorthand Notation
Let’s declare a logArea method in our shape object:
1 let shapeName = 'Rectangle', a = 5, b = 3;
2
3 let shape = {
4 shapeName,
5 a,
6 b,
7 logArea() { console.log( 'Area: ' + (a*b) ); },
8 id: 0
9 };
Notice that in ES5, we would have to write : function between logArea and () to make the same declaration work. This syntax is called the concise method syntax. We first used the concise method syntax in Chapter 4 - Classes.
Concise methods have not made it to the specification just to shave off 10 to 11 characters from the code. Concise methods also make it possible to access prototypes more easily. This leads us to the next section.
Computed Object Keys
In JavaScript, objects are associative arrays (hashmaps) with String keys. We will refine this statement later with ES6 Symbols, but so far, our knowledge is limited to string keys.
It is now possible to create an object property inside the object literal using the bracket notation:
1 let arr = [1,2,3,4,5];
2
3 let experimentObject = {
4 arr,
5 [ arr ]: 1,
6 [ arr.length ]: 2,
7 [ {} ]: 3
8 }
The object will be evaluated as follows:
1 {
2 "1,2,3,4,5": 1,
3 "5": 2,
4 "[object Object]": 3,
5 "arr": [1,2,3,4,5]
6 }
We can use any of the above keys to retrieve the above values from experimentObject:
1 experimentObject.arr // [1,2,3,4,5]
2 experimentObject[ 'arr' ] // [1,2,3,4,5]
3 experimentObject[ arr ] // 1
4 experimentObject[ arr.length ] // 2
5 experimentObject[ '[object Object]' ] // 3
6 experimentObject[ experimentObject ] // 3
Conclusions:
- arrays and objects are converted to their
toStringvalues -
arr.toString()equals the concatenation of thetoStringvalue of each of its elements, joined by commas - the
toStringvalue of an object is[object Object]regardless of its contents - when creating or accessing a property of an object, the respective
toStringvalues are compared
Equality
We will start with a nitpicky subject: comparisons. Most developers prefer === to ==, as the first one considers the type of its operands.
In ES6, Object.is( a, b ) provides same value equality, which is almost the same as === except the following differences:
-
Object.is( +0, -0 )isfalse, while-0 === +0istrue -
Object.is( NaN, NaN )istrue, whileNaN === NaNis false
I will continue using === for now, and pay attention to NaN values, as they should normally be caught and handled prior to a comparison using the more semantic isNaN built-in function. For more details on Object.is, visit this thorough article.
Mixins and Shallow Copies with Object.assign
Heated debates of composition over inheritance made mixins appear as the winner construction for composing objects. Therefore, libraries such as UnderscoreJs and LoDash created support for this construct with their methods _.extend or _.mixin.
In ES6, Object.assign does the exact same thing as _.extend or _.mixin.
Why are mixins important? Because the alternative of establishing a class hierarchy using inheritance is inefficient and rigid.
Suppose you have a view object, which can be defined with or without the following extensions:
- validation
- tooltips
- abstractions for two-way data binding
- toolbar
- preloader animation
Assuming the order of the extensions does not matter, 32 different view types can be defined using the above five enhancements. In order to fight the combinatoric explosion, we just take these extensions as mixins, and extend our object prototypes with the extensions that we need.
For instance, a validating view with a preloader animation can be defined in the following way:
1 let View = { ... };
2 let ValidationMixin = { ... };
3 let PreloaderAnimationMixin = { ... };
4
5 let ValidatingMixinWithPreloader = Object.assign(
6 {},
7 View,
8 ValidationMixin,
9 PreloaderAnimationMixin
10 );
Why do we extend the empty object? Because
Object.assignworks in a way that it extends its first argument with the remaining list of arguments. This implies that the first argument ofObject.assignmay get new keys, or its values will be overwritten by a value originating from a mixed in object.
The syntax of calling Object.assign is as follows:
Object.assign( targetObject, ...sourceObjects )
The return value of Object.assign is targetObject. The side-effect of calling Object.assign is that targetObject is mutated.
Object.assignmakes a shallow copy of the properties and operations of...sourceObjectsintotargetObject.
For more information on shallow copies or cloning, check my article on Cloning Objects in JavaScript.
1 let horse = {
2 horseName: 'QuickBucks',
3 toString: function() {
4 return this.horseName;
5 }
6 };
7
8 let rider = {
9 riderName: 'Frank',
10 toString: function() {
11 return this.riderName;
12 }
13 };
14
15 let horseRiderStringUtility = {
16 toString: function() {
17 return this.riderName + ' on ' + this.horseName;
18 }
19 }
20
21 let racer = Object.assign(
22 {},
23 horse,
24 rider,
25 horseRiderStringUtility
26 );
27
28 console.log( racer.toString() );
29 > "Frank on QuickBucks"
Had we omitted the {} from the assembly of the racer object, seemingly, nothing would have changed, as racer.toString() would still have been "Frank on QuickBucks". However, notice that horse would have been === equivalent to racer, meaning, that the side-effect of executing Object.assign would have been the mutation of the horse object.
Destructuring objects
In the scope where an object is created, it is possible to use other variables for initialization.
1 let shapeName = 'Rectangle', a = 5, b = 3;
2
3 let shape = { shapeName, a, b, id: 0 };
4
5 console.log( shape );
6 // { a: 5, b: 3, id: 0, shapeName: "Rectangle" }
It is possible to use this shorthand in destructuring assignments for the purpose of creating new fields:
1 let { x, y } = { x: 3, y: 4, z: 2 };
2
3 console.log( y, typeof y );
4 // 4 "number"
Spreading objects
The spread operator and rest parameters have been a popular addition in ES2015. You could spread arrays to comma separated values, and you could also add a rest parameter at the end of function argument lists to deal with a variable number of arguments.
Let’s see the same concept applied for objects:
1 let book = {
2 author: 'Zsolt Nagy',
3 title: 'The Developer\'s Edge',
4 website: 'devcareermastery.com',
5 chapters: 8
6 }
We can now create an destructuring expression, where we match a couple of properties, and we gather the rest of the properties in the bookData object reference.
1 let { chapters, website, ...bookData } = book
Once we create this assignment, the chapters numeric field is moved into the chapters variable, website will hold the string ‘devcareermastery.com’. The rest of the fields are moved into the bookData object:
1 > bookData
2 {author: "Zsolt Nagy", title: "The Developer's Edge"}
This is why the …bookData is called the rest property. It collects the fields not matched before.
The rest property for objects works in the same way as the rest parameter for arrays. Destructuring works in the exact same way as with arrays too.
Similarly to rest parameters, we can use rest properties to make a shallow copy of an object. For reference, check out my article cloning objects in JavaScript
1 let clonedBook = { ...book };
You can also add more fields to an object on top of cloning the existing fields.
1 let extendedBook = {
2 pages: 250,
3 ...clonedBook
4 }
The spread syntax can be expressed using Object.assign:
1 let book = {
2 author: 'Zsolt Nagy',
3 title: 'The Developer\'s Edge',
4 website: 'devcareermastery.com',
5 chapters: 8
6 }
7
8 // let clonedBook = { ...book };
9 let clonedBook = Object.assign( {}, book );
10
11 // let extendedBook = {
12 // pages: 250,
13 // ...clonedBook
14 // }
15 let extendedBook = Object.assign( {pages: 250}, clonedBook );
Symbol keys
In ES6, the Symbol type was introduced. Each symbol is unique. Even if two symbols are associated with the same label, they are different:
1 > Symbol( 'label' ) == Symbol( 'label' )
2 false
JavaScript allows both strings and symbols as object keys. This makes it possible to define your own identifiers that are unique, without the need to worry about whether there is another resource using the same identifier. For instance, if we create a global Symbol( 'jQuery' ), and there is another global Symbol( 'jQuery' ) coming from another file, the two values can co-exist, and they will not be equal.
In reality, the above benefit does not result in well maintainable code, but at least it is one way to resolve conflicts.
Without placing judgement on what counts as maintainable or unmaintainable, JavaScript allows you to use Symbol keys:
1 const key = Symbol( 'key' );
2
3 const o = {
4 [key]: true,
5 [Symbol( 'key2' )]: true
6 }
Be aware of the following featues of Symbol keys:
1. their value does not appear in the JSON form of an object:
1 > JSON.stringify(o)
2 "{}"
2. their value does not appear in for..in, Object.keys, Object.entries, Object.getOwnPropertyNames enumerations:
1 > for ( let p in o ) console.log( p )
2 undefined
3
4 > Object.keys( o )
5 []
6
7 > Object.entries( o )
8 []
9
10 > Object.getOwnPropertyNames( o )
11 []
3. their value does appear in Object.getOwnPropertyDescriptors:
1 > Object.getOwnPropertyDescriptors( o )
As a consequence, Symbol keys are not a bulletproof way for indicating private properties.
4. their value does appear in shallow copies:
1 > {...o}
2 {Symbol(key): true, Symbol(key2): true}
3
4 > Object.assign( {}, o )
5 {Symbol(key): true, Symbol(key2): true}
Array-Like Objects
In JavaScript, there are some objects that are not arrays, but they are like arrays. Some examples of these objects is the arguments object of functions:
1 function variableArguments() {
2 return arguments;
3 }
4
5 const args = variableArguments( 1, 2, 3, 4 );
6
7 console.log( args )
8 // Arguments(4) [1, 2, 3, 4, callee: ƒ, Symbol(Symbol.iterator): ƒ]
Array methods in array-like objects are not defined:
1 > args.map
2 undefined
3
4 > args.push
5 undefined
In old versions of JavaScript, before the spread operator was invented in ES6, this was the only way to write a variable number of arguments. In newer versions of JavaScript, the use of the arguments object is discouraged. Use rest parameters instead. In the below example, args is a real array:
1 function variableArguments2( ...args ) {
2 return args;
3 }
4
5 const args2 = variableArguments2( 1, 2, 3, 4 );
6
7 console.log( args2 );
8 // [1, 2, 3, 4]
In general, an array-like object looks like the following:
1 const A = {
2 0: 'value0',
3 1: 'value1',
4 length: 2
5 }
In this object, A[1] returns value1. A.length returns 2.
Array-like objects are not real arrays, because they don’t have some array properties and operations such as: map, reduce, filter, slice, splice,npush, pop, append, concat, join etc.
The reason why the above methods are not available for array-like objects is that they inherit from Object.prototype, not from Array.prototype. Proof:
1 A.constructor
2 ƒ Object() { [native code] }
3 B.constructor
4 ƒ Array() { [native code] }
Array-like objects can be converted to an array in many ways:
-
Array.from( args ), -
Object.values( args ), -
[...args].
Before ES6, due to using array-like objects, functional programming techniques such as currying were harder to understand.
When using variable number of arguments, use rest parameters.
Remember, Array.isArray determines if a reference points at an array or an array-like object:
1 const A = {
2 0: 'value0',
3 1: 'value1',
4 length: 2
5 }
6
7 Array.isArray( A ); // false
8 Array.isArray( Array.from( A ) ); // true