Notes on

Eloquent JavaScript

by Marijn Haverbeke

| 11 min read


Learning is hard work, but everything you learn is yours and will make further learning easier.

There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.
C.A.R. Hoare, 1980 ACM Turing Award Lecture

Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.
Brian Kernighan and P.J. Plauger, The Elements of Programming Style

Too bad! Same old story! Once you’ve finished building your house you notice you’ve accidentally learned something that you really should have known—before you started.
Friedrich Nietzsche, Beyond Good and Evil

Use the entire language

Some programmers believe that this complexity is best managed by using only a small set of well-understood techniques in their programs. They have composed strict rules (“best practices”) prescribing the form programs should have and carefully stay within their safe little zone.
This is not only boring, it is ineffective. New problems often require new solutions. The field of programming is young and still developing rapidly, and it is varied enough to have room for wildly different approaches. There are many terrible mistakes to make in program design, and you should go ahead and make them at least once so that you understand them. A sense of what a good program looks like is developed with practice, not learned from a list of rules.

Any kind of predetermined “this is how we do things around here” isn’t great. But it isn’t entirely bad, either! Those kinds of heuristics/rules often come from the collective experience of some community, and should be understood before broken.

But I do think that there’s so much room to learn new things & experiment, and that the ‘best practices’ don’t hold forever.

Introduction

JavaScript was introduced in 1995 to add programs to web pages in Netscape Navigator.

After it was adopted outside Netscape, the ECMAScript standard was written to describe the way JavaScript should work. ECMA comes after the Ecma International organization that did the standardization. So ECMAScript and JavaScript refers to the same language.

Values, Types, and Operators

JavaScript uses 64 bits to store a single number value.
With decimal digits, you can represent numbers.
Given 64 binary digits, you can represent different numbers, so about numbers.
But not all numbers less than that can fit in a JS number. The bits also store negative numbers – we use one bit to indicate the sign. We also bits to store the position of the decimal point (for nonwhole numbers). So we can actually store about .

Calculations with integers below that are guaranteed to be precise.
Calculations with fractional numbers are generally not. We lose precision.

Strings are also modeled as a series of bits.
JS models its approach based on the Unicode standard.
It uses Unicode, but only 16 bits per string (UTF-16 to encode strings. 16 bits can describe up to 216 different characters), which isn’t enough to cover the amount of characters that Unicode defines. So some characters (e.g., many emojis) take two character positions in JS strings.

// Two emoji characters, horse and shoe
let horseShoe = "🐴👟";

console.log(horseShoe.length);
// → 4

console.log(horseShoe[0]);
// → (Invalid half-character)

console.log(horseShoe.charCodeAt(0));
// → 55357 (Code of the half-character)

console.log(horseShoe.codePointAt(0));
// → 128052 (Actual code for horse emoji)

This is very illustrative of the issue with UTF-16 in JS strings. The length says 4 characters, but it’s just two emojis.

And you can’t just index by 0. You’d have to use charPointAt to get the actual code for the emoji.

To get the actual amount of characters (visible), you’d have to use: const x1 = Array.from("🎉😀🤣😡💰⭐🍵😵"). Or [..."🍵🍵"] and then get the length.

You can iterate through the symbols individually. E.g. just use a for .. of loop over the string.

Type Coercion

When we apply an operator to the ‘wrong’ type of value, JS will quietly convert the value to the type it needs, using a specified set of rules.
This is called type coercion.

Short-circuiting of logical operators

The logical operators && and || convert the value on their left side to Boolean type to decide what to do, but depending on the operator and the result of the conversion, they return either the original left-hand value or the right-hand value.

For example, || returns the left value when that value can be converted to true and the right value otherwise.

Similarly, && returns the left value when that value can be converted to false and the right value otherwise.

false && 'yes' // false
true && 'yes' // 'yes'

The ?? operator (nullish coalescing) returns the left value when that value is not null or undefined, and the right value otherwise.

Functions

Closures

A function that references bindings from local scopes around it is a closure.

function createCounter(): () => number {
  let count = 0;

  return function(): number {
    count += 1;
    return count;
  };
}

const counter1 = createCounter();
const counter2 = createCounter();

console.log(counter1()); // Output: 1
console.log(counter1()); // Output: 2
console.log(counter2()); // Output: 1
console.log(counter2()); // Output: 2

When createCounter is called, it creates a new execution context with its own count variable.
The returned function forms a closure over count, meaning it retains access to it even after createCounter has finished executing.

Objects

To declare a private method, put a # sign in front of its name.
Those methods can only be called from inside the class declaration that defines them.

class SecretiveObject {
	#getSecret() {
		return "I ate all the plums";
	}

	interrogate() {
		const shallISayIt = this.#getSecret();
		return "never";
	}
}

Maps

Don’t use objects as maps.

Plain objects derive from Object.prototype, which means they will have properties that you didn’t define.

You could create objects with no prototypes with Object.create(null).

Object property names must be strings. If you need a map whose keys can’t easily be converted to strings, you can’t use objects.

Fortunately, JS has a Map class. So prefer using it over plain objects for this purpose.

If you do need to use plain objects, know that:

  • Object.keys returns only the object’s own keys, not those in the prototype
  • You can use Object.hasOwn, which ignores the object’s prototype (in does not!).

The Iterator Interface

A for/of expects an iterable, meaning the object it’s given should have a method with the symbol Symbol.iterator as its name.
Symbol.iterator is a symbol defined by the language and is stored as a property of the Symbol function.

The method should return an object that provides a second interface, iterator. This is what’s being iterated. It has a next method that returns the next result, which should be an object with a value property providing the next value, if there is one, and a done property denoting whether there are no more results by true and false otherwise.

const okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next()); // → {value: "O", done: false}
console.log(okIterator.next()); // → {value: "K", done: false}
console.log(okIterator.next()); // → {value: undefined, done: true}

Symbol is a primitive datatype & represents a unique identifier. Each Symbol value is guaranteed to be unique and immutable, and as such, are often used as property keys when you want to ensure no naming conflicts.

Symbol.iterator is one of these symbols. It’s used to define the default iterator for an object. When an object implements the Symbol.iterator method, it becomes iterable, and can be used in language constructs that expect iterables, like for...of loops, the ... spread operator, Array.from(), and so on.

Regular Expressions

Character Classes

Built-in Shortcuts
  • \d: Any digit character (equivalent to [0-9])
  • \w: An alphanumeric character (“word character”)
  • \s: Any whitespace character (space, tab, newline, etc.)
  • \D: A character that is not a digit
  • \W: A nonalphanumeric character
  • \S: A nonwhitespace character
  • .: Any character except for newline
Inverted Character Sets

Use ^ after the opening bracket to match any character except those in the set.

let nonBinary = /[^01]/;
console.log(nonBinary.test("1100100010100110")); // → false
console.log(nonBinary.test("0111010112101001")); // → true

Unicode Properties and Support

You can use \p in regular expressions to match all characters to which the Unicode standard assigns a given property:

  • \p{L}: Any letter
  • \p{N}: Any numeric character
  • \p{P}: Any punctuation character
  • \P{L}: Any nonletter (uppercase P inverts)
  • \p{Script=Hangul}: Any character from the given script

Note: Avoid using \w for non-English text processing. It doesn’t treat character’s like é as letters. \p property groups are more robust.

Make sure you add a u flag after the regular expression for Unicode support.
Adding u flag for proper Unicode character handling:

console.log(/🍎{3}/u.test("🍎🍎🍎")); // → true
console.log(/<.>/u.test("<🌹>")); // → true

Quantifiers

  • {n}: Exactly n occurrences
  • {n,m}: Between n and m occurrences
  • {n,}: n or more occurrences
  • ?: Zero or one occurrence
  • *: Zero or more occurrences
  • +: One or more occurrences

Look-ahead Tests

Look-ahead tests check if the input matches a certain pattern without actually moving to the next match position. They are written between (?= and ).

  • Positive look-ahead: (?=pattern)
  • Negative look-ahead: (?!pattern)
console.log(/a(?=e)/.exec("braeburn")); // → ["a"]
console.log(/a(?! )/.exec("a b")); // → null

In the first example, the e is needed to make a match, but it is not included in the matched text.
The (?! ) means a negative look-ahead, which only matches if the pattern in the parentheses does not match. This causes the second example to only match a characters that do not have a space after them.

Greediness

Motivating this by an example. We may want to remove comments from JS using replace:

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*\*\//g, "");
}

However, that output can be incorrect due to the greedy nature of the [^]*. For example:

console.log(stripComments("1 /* a */+/* b */ 1")); // Incorrectly outputs: 1  1

Greedy operators (+, *, ?, {}) match as much as possible and backtrack if necessary.
In the example above, [^]* first matches the entire remaining string, then backtracks to find the nearest */, leading to incorrect removal of comments.

We can add add ? after them to make them non-greedy (e.g., *?). This makes the matcher consume the smallest possible portion that satisfies the pattern:

function stripComments(code) {
  return code.replace(/\/\/.*|\/\*[^]*?\*\//g, "");
}
console.log(stripComments("1 /* a */+/* b */ 1")); // → 1 + 1

Prefer nongreedy variants (+?, *?, ??, {}?) when using repetition operators to avoid unintended greedy matches.
By using nongreedy operators, you ensure that the regular expression matches only as much as necessary, avoiding issues with backtracking and incorrect matches.

Dynamically Creating RegExp Objects

When dynamically creating regular expressions, handle special characters to ensure the regex works correctly.

let name = "dea+hl[]rd";
let escaped = name.replace(/[\\[.+*?(){|^$]/g, "\\$&");
let regexp = new RegExp("(^|\\s)" + escaped + "($|\\s)", "gi");

General Patterns

  • /abc/: A sequence of characters
  • /[abc]/: Any character from the set of characters
  • /[^abc]/: Any character not in the set of characters
  • /[0-9]/: Any character in a range (e.g., 0 to 9)
  • /x+/: One or more occurrences of the pattern x
  • /x+?/: One or more occurrences of the pattern x (nongreedy)
  • /x?/: Zero or one occurrence of the pattern x
  • /x{2,4}/: Two to four occurrences of the pattern x
  • /(abc)/: A group
  • /a|b|c/: Any one of several patterns
  • /\d/: Any digit character
  • /\w/ An alphanumeric character (word character)
  • /\s/: Any whitespace character
  • /./: Any character except newlines
  • /^/: Start of input
  • /$/: End of input

Use appropriate flags (like g for global, i for case-insensitive, u for Unicode) as needed.

Modules

Why bundle: it’s faster than loading a ton of small files because the network overhead will be the bottleneck.

Why we compile JS: to use other dialects, new features that haven’t been released yet.

Why minify: to reduce bundle size.

Asynchronous Programming

The JavaScript environment only runs one program at a time.
Like a big loop around your program (called the event loop).
It’s just waiting for events. And when one arises, it runs it.

The mental model for the event loop is simple: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop

Because no two things run at the same time, slow-running code can delay the handling of other events.

Exceptions in callbacks

Promises have made exception handling much easier.
Asynchronous behavior happens on its own empty function call stack.
Since each callback starts with a mostly empty stack, catch handlers won’t be on the stack when they throw an exception.

So this error won’t get caught:

try {
	setTimeout(() => {
		throw new Error("Erroring");
	}, 20);
} catch (e) {
	// Won't run:
	console.log("Catching error", e);
}

Events

  • Event propagation: Handlers on parent nodes with children receive events from children
    • If both they both the child and parent has an event handler, then the specific handlers take precedence over general ones
    • Event propagates outward from where it occurred to parent nodes and document root, calling the most specific handlers first
    • stopPropagation method can prevent event from reaching higher handlers
  • Many events have default actions (e.g., clicking a link takes you to the target, pressing down arrow scrolls down, right-click shows context menu).
    • preventDefault can be used to not run default event actions

Liked these notes? Join the newsletter.

Get notified whenever I post new notes.