Sam Johnston - Senior Software Engineer

Sam Johnston

Senior Software Engineer
AWS, NodeJS, React

Friday, 2 April 2021

Home » Articles » JavaScript: The Definitive Guide

JavaScript: The Definitive Guide

JavaScript: The Definitive Guide

A collection of useful notes I took whilst reading Javascript: The Definitive Guide, 7th Edition by David Flanagan

Contents

  1. JavaScript Basics
  2. Objects
  3. Arrays
  4. Functions
  5. Classes
  6. Modules
  7. Standard Library

JavaScript Basics

JavaScript expressions are "phrases" that will evaluate to a value.

JavaScript statements are "sentences" that end with a semi-colon. Statements are executed and expected to make something happen.

if (expression) {
  statement();
}

First defined operator

First defined operator or nullish coalescing operator (??) is a logical operator that returns its right-hand side operand when its left-hand side operand is null or undefined, and otherwise returns its left-hand side operand.

// This is no good if zero is a valid value, as it's falsy
let max = maxWidth || m.width || 500;

// Switching to the first defined operator solves this issue
let max = maxWidth ?? m.width ?? 500;

globalThis

ES2020 introduces globalThis to replace window and global.

Assignment with Operation

let total = total + salesTax;
let total += salesTax;

Short Circuiting

Javascript expressions are evaluated from left to right and always products a result, or return something.

function saySomething() {
  let greeting = 'hello';
  let returnValue = '_world';

  return greeting && returnValue; // '_world'
  return greeting + returnValue; // 'hello_world'
}

Eval

Always be deeply concerned about as eval statement. It's a security risk/ Eval accepts a string of text that is valid JS, will be executed.

Debugger

Use the debugger statement to halt execution. You can use the dev tools to work out where a problem is in the call stack. For example:

function myBrokenFunc(value) {
  if (value === undefined) {
    debugger();
  }

  return value;
}

Dev tools will halt execution exactly when myBrokenFunc is invoked with no value, allowing you to trace the calls in the call stack and fix.

Typeof Operator

Typeof is only useful when you need to distinguish between an object and a primitive type:

if (typeof value === 'string') {
  console.log(string);
}

InstanceOf Operator

Evaluates to true if the left side object is an instance of the right side class.

const today = new Date();
if (today instanceof Date) {
  console.log(hello);
}

Switch Statement

Use a switch statement when all conditional branches depend on the value of the same expression:

if (number === 1) {
  console.log('one');
} else if (number === 2) {
  console.log('two');
} else {
  console.log('gimme a number');
}

// VS

switch (number) {
  case 1:
    console.log('one');
  case 2:
    console.log('two');
  default:
    console.log('gimme a number');
}

If Else

FUN FACT: Is not a fully qualified statement like elsif in PHP. It's two statements combined.

Hoisting

Hoisting is when a variable declaration is lifted/moved to the top of the enclosing function / execution context this means you can use it before it is defined.

Symbols

A symbol is a symbolic reference to a value. It's always unique but will refer to it's initial value. Very useful for extending a third party object and you want to bve sure nothing is overwritten.

Enumerable

An enumerable property simple means that it can be viewed when it its iterated upon, for example by using Object.keys(obj)

If a property is created by means of assignment, it is enumerable by default. To hide a property and prevent it being iterable you must use Object.defineProperty(...)

Object.defineProperty(myObj, 'new-property', {
  value: 'am new',
  enumerable: false,
  writeable: false,
  configurable: false,
});

Objects

Cloning Objects

// Overwrites everything in obj with defaults
Object.assign(obj, defaults);

// Creates a new object, copies the defaults first, the overwrites with overrides
const newObj = object.assign({}, defaults, overrides);

// Prefer spread operator
const newObj = { ...defaults, ...overrides };

Underscores

Underscores that prefix a property name in an object generally hint that the property is for internal use only.

Prototypal Inheritance

Simple means that properties of an object are inherited from its parent.

Some methods on the Object.prototype are intended to be replaced:

let myObj = {
  x: 1,
  y: 2,
  toString: () => 'am string that represents the object - i need to be defined',
};

myObj.toString(); // am string that represents the object - i need to be defined

// Otherwise this would simply be [Object, object]

Getters and Setters

Getters and setters are property accessors as opposed to data properties. They can be used to create a robust, reliable interface to data properties.

let myObj = {
  name: 'Sam',
  get name() {
    return this.name.toLowercase();
  },
  set name(name) {
    thi.name = name;
  },
};

If a data property has both a getter and setter it can be considered a read/write property.

If a data property has only a getter it can be considered a read only property.

If a data property has only a setter it can be considered a write only property, ideally this property should return undefined.

Arrays

Array Iterator Methods

ForEach()

ForEach() passes each element of your array to the function you provided and can do something with each element, it mutates the original array if you configure it do so.

map()

Map() passes each element of your array to the function you provided and returns a new array of any return values from that function. (ie: it creates a mapping from the provided array)

filter()

Filter() returns an array containing a subset of values from the original array. The function you provide is a predicate function - one that returns true or false given the condition.

let array = [1, 2, 3, 4, 5];
array.filter((element) => element < 3); //returns [1,2]

every() and some()

These are predicate functions in their own right. They return true if every, or some of the elements match your provided function.

reduce() && reduceRight()

Reduce() accepts two arguments, the first is the actual reducer function, the second is an option initial value. The reducer function aims to "reduce" the provided array by accumulating a value based from the current iterated value.

concat()

Concat() concatenates arrays and flattens them one level down. It will return the resulting array.

let array = [1, 2, 3];
array.concat([4, 5, 6]); // returns [1,2,3,4,5,6]

indexOf()

IndexOf() searches an array for a given value and then returns that values index, if it exists. If the value can not be found it returns -1.

includes()

Includes() accepts a single argument and returns true if it can be found in the array, or otherwise false.

sort()

Sort() by defaults sorts an array in ascending order, numbers first, followed by alphabetical (case sensitive) values. Or you can provide your own sorting algorithm. The function you provide will return a number. If the number is negative it is sorted to the beginning, or if it is greater than zero, it is sorted to the end. If the number is zero, this position stays the same for that element.

Array Prototype Methods

Array.of()

Allows for the creation of arrays with a single element

let newArray = Array.of(10); // [10]

// VS

let newArray = new Array(10); // []

Array.from()

Array.from() is useful for when working with array like responses (objects with a length property, Maps, Sets WeakMap WeakSet etc)

Array.from() accepts a optional argument which is a function, similar to mpa() but using this is more efficient, because the array has not yet been created.

Array.from(['a','b','c']), (element) => element.toUpperCase()) // ['A','B','C']

Iterating Arrays

In ES6 there are two main ways to iterate through an array; forEach and for/of loops

The forEach() array method is preferred for functional programming applications, otherwise feel free to use the for/of loop.

let string = 'hello world';
for (let char of string) {
  console.log(char);
}

for (let [index, char] of string.entries()) {
  console.log(char);
  console.log(index);
}

Functions

Function declarations

Functions declared in a block of javascript code are available throughout that block. They will be defined before the interpretor executes any code in the block.

ie: They are hoisted to the top of the block.

Function expressions

It is up to you to assign a function expression to a variable, uf you are going to need to call it again. Or alternatively you can name the function so it can be used recursively.

Function expressions are not hoisted, if one is assigned to a variable it is not ready to be used until that express has been evaluated.

// Function expression
function square(x) {
  return x * x;
}

// named function expression
const f = function factorial(x) {
  if (x <= 1) {
    return 1;
  } else {
    return x * factorial(x - 1);
  }
};

// Anonymous function expression
[3, 2, 1].sort((a, b) => a - b); // [1,2,3]

// IIFE
(function (x) {
  return x * x;
})(11);

// Arrow function
const myFunc = (someArg) => someArg;

Arrow Functions

Do not have their own invocation context. They inherit this from the environment in which they are defined.

They also do not inherit a prototype property so can not be used as constructors

Recursive Functions and The Call Stack

When a function calls another function a new execution context is added to the stack. When execution is complete, the context is removed from the stack and returns to the previous function, until all executions have completed. It's important to consider memory when working with recursive functions that call themselves. Too many stacks can result in a "maximum call stack size exceeded" error

Indirect Invocations

Javascript functions are objects, and like all objects they have their own methods.

.call() and .apply() invoke the function indirectly. Both methods accept two arguments; the first, a value for this which is used as the execution context, and an optional second argument which are the arguments to provide to the called function. .apply() requires this to be an array of arguments, but .call() simply accepts n* arguments.

Functions as Namespaces

It's sometimes useful to define a function to simply act as a temporary namespace where you can set variables without cluttering the global namespace

Closures

Remember the fundamental rule of lexical scoping: JavaScript functions are executed using the scope they were defined in

Technically, all javascript functions are closures but because most functions are executed from the same scope they were defined in, it doesn't normally matter that a closure was involved.

Every closure has three scopes:

  • The local scope (own scope)
  • The outer function scope
  • The global scope

Closures are useful because the let you associate data (the lexical environment => variables), with a function that operates on that data.

function makeBig(size) {
  // returning a function is key to closures so it can later be invoked from another scope
  return function () {
    // this is a closure because we always have access to fontSize if we assign the function to a variable
    document.body.style.fontSize = size + 'px';
  };
}

var makeTen = makeBig(10);
var makeTwenty = makeBig(20);

makeTen();

Function Properties, Methods and Constructors

The length property

Specifies the "arity" of a function; the number of parameters it declares in its parameter list, which is usually the number of arguments the function expects.

Note: ...rest is excluded from the length and not counted.

call() and apply()

These function prototype methods allow you to "call through" another object, temporarily changing the this keyword for that invocation.

After the "this" parameter, .call() accepts an unlimited number of additional parameters which are used as the arguments to the function you are invoking.

.apply(), acts the same as .call() but instead accepts an array of arguments as its second parameter.

.bind() is similar to the above but does not call the function. It allows you to save the bound function to a variable so it can be executed at a later time, all supplied arguments are also bound to the saved function, so can me omitted when the method is to be invoked.

var savedFunction = makeBig.bind(this, 10);
savedFunction();

Higher Order Functions

Any function that accepts a function as an argument (technically a callback if its invoked) or returns another function can be known as a higher order function.

The help to ensure functions are generalized and that your code is DRY.

Recursion

A textbook example of recursion is progressing down through a file tree of multiple levels.

Put simply thought, it is a function that calls itself as many times as it needs to until a condition is met.

The condition is called a base condition, once it is met the function finally returns

Recursion can be used to transform nested objects into arrays, or to create tree structures from otherwise flat data

// A trivial recursive function...
const countDownFrom = (number) => {
  if (number === 0) return;
  console.log(number);
  countDownFrom(number - 1);
};

Classes

In JavaScript classes use prototype based inheritance and we can say that if two or more objects inherit methods or values (state, props) from the same prototype, then we can say that they are instances of the same class.

Constructor functions define, in a sense, classes, and classes have names by convention that begin with capital letters.

Pre ES6 - Constructor Functions

function SayHello(name, greeting) {
  this.name = name;
  this.greeting = greeting;
}

// Typically you do not want to do this, as it overwrites the whole prototype object, normally you want to set a key explicitly
// ie: SayHello.prototype.printMessage = {...}
SayHello.prototype = {
  printMessage: function () {
    return `Hi ${this.name}, ${this.greeting}`;
  },
};

let Sam = new SayHello('sam', 'how are you?');
Sam.name; // sam
Sam.printMessage(); // Hi sam, how are you?

ES6 Classes

Are fundamentally the same as ES5 function constructors the are really just syntactic sugar.

class SayHello {
  constructor(name, greeting) {
    this.name = name;
    this.greeting = greeting;
  }

  printMessage = function () {
    return `Hi ${this.name}, ${this.greeting}`;
  };
}

let Sam = new SayHello('sam', 'how are you?');
Sam.name; // sam
Sam.printMessage(); // Hi sam, how are you?

All code within the body of a class declaration is implicitly ruled by strict mode.

Class declarations are not hoisted. You cannot instantiate a class before you declare it.

Static Methods

You can define a static method on a class by prefixing the method with the static keyword.

Static methods are defined as properties of the constructor not the prototype.

Static methods should avoid using this.

class ClassStub {
  static parse(string) {
    return string;
  }
}

//  Static methods are called directly on the class whereas normal class methods are called from the instantiated version of a class.
// ie: they don't need the new keyword
let string = ClassStub.parse('hello');

Subclasses

In object oriented programming a Class B can extend or "subclass" another Class A. We say that A is the "superclass" and B is the "subclass".

Instances of B inherit all the methods of A, but can be extended or even have specific methods overwritten.

ES6 introduces the extends clause to a class declaration to allow us to simply make a superclass.

If you extend a class, then the constructor for your class must call super() in order to invoke the constructor of the super class.

If your class does not have a constructor then one will be defined automatically, it'll pass all the values to super().

You may not use the this keyword in your constructor, without first calling super(), this enforces a rule whereby superclasses get to initialise themselves before a subclass does.

You can treat super() as an object and call methods on the original superclass, even if the subclass overwrote them. super.set(key, value)

Delegation instead of inheritance

It is often easier and more useful to create a new class that creates instances of other classes, rather than extending them.

This is known as object composition and one should favour composition over inheritance. Inheritance (extending) is often optional.

Modules

Modularity is mostly about encapsulating or hiding private implementation details and keeping the global namespace clean so that modules can not accidentally modify the variables, functions and class defined by other modules.

It also helps code management and allows for clear, concise and distinct code organisation.

ES5 and before had no build in module support so developers relied on classes, objects and closures to do this.

Methods on a object are scoped to that object and so do not pollute the global namespace.

By using an IIFE you can hide away private implementation details and store the return value inside a variable, this will ensure the whole function gets run, but only the return values serves as the public API.

NodeJS

In NodeJS each file is an independent module and the constants, functions contained within are private to that file, unless explicitly exported.

Node modules import other modules with the require function and export and public api using module.exports.

const myFunction = () => 42;

module.exports = myFunction;

ES6 Modules

These use default and named exports and can by used by using the keywords import and export.

// default import
import something from './somewhere';
// named import
import { somethingElse } from './somewhere';

//named export
export const myNamedExport = () => 42;

//default export
export default myDefaultExport;

We can also rename exports when importing them if their namespaces clash or we want to use something nicer

import { default as prettyName } from './somewhere';
import { fuglyFunction as wellHelloThere } from './somewhere';

We can also import all name exports at once with a wildcard. this will ass them all to a new namespace.

import * as something from './something';
something.myExportedMethod();
something.else();
something.etc();

Re exporting

By re exporting symbols we give the consumer more control on what the are importing.

//someFile.js
export { mean } from './stats/mean';
export { standardDeviation } from './stats/dev';

import { mean, standardDeviation } from 'someFile.js';

Dynamic imports

You can dynamically import files/modules using the import function

async function analyzeData(data) {
  let statsModule = await import('./path/to/module');

  return {
    average: statsModule(data),
  };
}

const result = await analyzeData(data);

The JavaScript Standard Library

The following subsections make up what can be considered the standard library of javascript

The Set Class

A Set is a collection of values, similar to an Array however a set is not ordered or indexed and duplicated values are not allowed. Any given value is either a member of a Set, or not a member.

It is not possible to ask how many times a value appears in a set.

Create a Set object by calling the constructor with any iterable object.

let mySet = new Set('sam'); // new Set, 3 elements "s", "a", "m"
mySet.size; // 3 - similar to .length on Arrays

Set membership is based on strict equality === so a set can contain both 1 and "1".

After a set has been initialised you can use three methods ot manage the membership of it. add(), clear() and delete()

mySet.add('johnston');

mySet.delete('s');

mySet.clear();

Sets are optimised for membership testing. it is better to use the mySet.has() method over the includes() of the Array type.

In practise you'll only want to use Sets for membership testing, and you should always use .has().

Sets are iterable however, so if you need to work with one after the fact you can convert them.

const myArray = [...mySet];

Sets do not have indexes but you can rely on the order of a set to be the order in which you added elements to it.

// forEach method of a set, only allows one arg, index does not exist
mySet.forEach((element) => console.log(element));

The Map Class

A map objects a set of values, known as keys, where each key has another value associated to it, or "mapped" to it.

Similar to Arrays, but instead of sequential integers as keys, Array[3], maps allow for arbitrary values that can be used as indexes.

Similar to Set's we have the set(), get(), has(), delete() and clear() methods available on any given Map.

If set()is called with a key that already exists, it's corresponding value will be updated.

You can rely on the order of a Map to be in the order in which you added elements to it (same as a Set).

You can easily destructure a Map to get all the keys and values out:

const myMap = new Map([
  ['key', 'value'],
  [1, 2],
]);
const all = [...myMap]; // [["key", "value"], [1, 2]]
const keys = [...myMap.keys()]; // ["key", 1]
const values = [...myMap.values()]; // ["value", 2]

WeakMap and WeakSet

These are variants of Map and Set that allow for their values to be garbage collected. If values inside a WeakMap or WeakSet, are no longer reachable or have no external references they will be selected for garbage collection.

The keys for WeakMap, and the value for a WeakSet must be objects or Arrays. Primitive values are not subject to garbage collection

Regular Expressions

A regular expression is an object that describes a textual pattern.

let literalPattern = /s$/;
let constructorPattern = new RegExp("s$);
  • [] A character group.
  • {} A repetition group.
  • [...] Any one character between the bracket group.
  • [^...] Any one character NOT between the bracket group.
  • . Any chracter except a new line terminator.
  • \w Any ASCI word character [a-zA-Z0-9_].
  • \s Any unicode whitespace character.
  • \d Any ASCI digit character [0-9].

Repetition Groups

  • {n, m} Match the previous item at least n times, but no more than m times.
  • {n, } Match the previous item as least n times or more.
  • {n} Match the previous item exactly n times.
const digitsRegex = new RegExp("\d{2,4}"); // match numbers with at least 2 and at most 4 digits
const threeLetters = new RegExp("\s{3}"); // match three letter words 

Operators

  • | Logical OR statement ("hello"|"hi").
  • (...) Literal capture group ("find me').
  • ^ Denotes the start of a regular expression string (^\w{3}) // 123, 123456.
  • $ Denotes the end of a regular expression string (^\w{3}$) // 123

Flags

  • g Global, find all matches within a string.
  • i Case Insensitive, matching will be case-insensitive.
  • m Multiline, matching with be multiline.

String Methods for Pattern Matching

search() Accepts a regular expression argument and returns either the position of the first matching string or -1 if one cannot be found

const regex = /script/i;
"JavaScript".search(regex) // 4
"Python".search(regex) // -1

replace() Accepts two arguments, the first being a regular expression to search for (or just a plain string), the second being the value to replace it with.

const string = 'The quick brown fox';
string.replace(/\b\w{3}\b/g, "") // The fox

match() Is the most useful and returns an array of all the matches found or null.

const string = 'The quick brown fox';
string.match(/\b\w{3}\b/g, "") // ["The", "fox"]

test() Is the simplest way to use regular expressions, it returns true or false

const pattern = /\w{3}/g;
pattern.test("123") // true

Dates and Times

const currentTime = new Date() // Returns the current date and time
const epochTime = new Date(0) // Returns the number of milliseconds since the 1970 epoch 

One quirk of the Date API is that the first month of a year is 0 but the first day of the month is 1

Dates are always in the local time of the machine (server or client), if you want a UTC (GMT) you can use:

const utcTime = new Date(Date.UTC());

If you console.log() a date, it will also by default be in the local time, to log it in UTC, you must explicitly convert it to a string with toUTCString(), or toISOString().

let now = new Date(); // Tue Jul 04 2021 15:09:34 GMT+0100 (British Summer Time)
now.getFullYear(); // 2021
now.getMonth(); // 6
now.getDate(); // 4
now.getHours(); // 15
now.getMinutes(); // 9
now.getSeconds(); // 34
now.getMilliseconds(); // 871
now.getUTCFullYear(); // 2021 (GMT)

now.setFullYear(2000); // Tue Jul 04 2000 15:09:34 GMT+0100 (British Summer Time)
now.setMonth(0); // Wed Jan 06 2021 15:09:34 GMT+0000 (Greenwich Mean Time)
now.setDate(10); // Sun Jan 10 2021 15:09:34 GMT+0000 (Greenwich Mean Time)
now.setHours(now.getHours() + 1) // Tue Jan 04 2000 16:09:34 GMT+0100 (British Summer Time)
now.setMinutes(now.getMinutes() + 30); // Tue Jan 04 2000 16:39:34 GMT+0100 (British Summer Time)
now.setSeconds(now.getSeconds() + 30); // Tue Jan 04 2000 16:40:04 GMT+0100 (British Summer Time)
now.setMilliseconds(1000); // Tue Jan 04 2000 15:09:35 GMT+0100 (British Summer Time)
now.setUTCFullYear(2021); // Tue Jul 06 2021 15:09:35 GMT+0100 (British Summer Time)

Timestamps

Internally dates are stored as integers that specify the number of milliseconds since midnight January 1 1970 UTC (The Unix epoch === 0, any time before this, is a negative integer). To get the internally stored date you can use now.getTime() or Date.now()

Date Arithmetic

Using timestamps works well for adjusting a date in seconds but you can also use the setDate() methods listed above to programmatically increase/decrease a date by a specified time frame. This works even if the the date overflows, for example if you add 16 months to a date, it will be incremented by 1 year and 4 months.

let now = new Date(); // Tue Jul 04 2021 15:09:34 GMT+0100 (British Summer Time)
now.setHours(now.getHours() + 1) // Tue Jan 04 2000 16:09:34 GMT+0100 (British Summer Time)
now.setMinutes(now.getMinutes() + 30); // Tue Jan 04 2000 16:39:34 GMT+0100 (British Summer Time)
now.setSeconds(now.getSeconds() + 30); // Tue Jan 04 2000 16:40:04 GMT+0100 (British Summer Time)

Formatting and Parsing Date Strings

It's best to avoid showing these dates to end users. If you need something quick and dirty you can use the below, but you should prefer the Intl.DateTimeFormat() class.

let now = new Date();
now.toString(); // "Tue Jul 06 2021 15:27:30 GMT+0100 (British Summer Time)"
now.toUTCString(); // "Tue Jul 06 2021 15:27:30 GMT+0100 (British Summer Time)"

Error Classes

The standard Error class has three properties:

name: "Error" || "EvalError" || "RangeError" || "SyntaxError" etc message: The value you passed to the error constructor toString(): returns "EvalError: ${message}"

There are built in subclasses for Errors which include:

"EvalError" || "RangeError" || "ReferenceError || "SyntaxError" || "TypeError" || "URIError"

But you should feel free to extend off Error and create your own subclass if you'd like something more specific.

class HTTPError extends Error {
  constructor(status, statusText, url){
    super(`${status} ${statusText}: ${url}`); // call the super class with the message.value
    this.status = status;
    this.statusText = statusText;
    this.url = url;
  }

  get Name(){
    return "HTTPError" //Overwrite the name property of the superclass
  }
}

const notFoundError = HTTPError(404, "Not Found", "https://samjohnston.co.uk/404")

notFoundError.status // 404
notFoundError.message // 404 Not Found: https://samjohnston.co.uk/404
notFoundError.name // "HTTPError"

JSON Serialisation and Parsing

JSON = JavaScript Object Notation

The process of converting data structures into streams of bytes or characters is known as serialisation. It is commonly needed when saving data or transferring data over the network.

JSON.stringify(data) Takes a data structure and converts it into a string

If your intention is console.log the result, you can provide an optional third argument:

JSON.stringify(data, null, 2) // indent the resulting object with 2 spaces per property.

JSON.parse(data) Takes previously stringified data structure and restores it to it's initial form

If the previously stringified data contains something that is not natively supported, OR instead implements its own toJSON() method, the resulting form will not match the original form. To solve this you can pass a method as the optional second parameter for both JSON.parse and JSON.stringify.

If you pass a function to JSON.parse() second argument - it is known as a reviver function.

If you pass a function to JSON.stringify() second argument - it is known as a replacer function.

// Example Reviver Function

let data = JSON.parse(text, function(key, value) {
  if (key[0] === "_") {
    return undefined // remove any values whose property starts with "_"
  }

  if (typeof value === "string" && isDateRegex.test(value)) {
    return new Date(value); // Return a new date object if the value is a valid ISO date string.
  }
  
  return value; // otherwise just return the value
})