#deck:exploring-js-cards-topics-preview #html:true #notetype:Basic #separator:; #columns:Front;Back;Tags "Which programming languages influenced JavaScript?";"
When JavaScript was created in 1995, it was influenced by several programming languages:
function
).
onclick
in web browsers.
The reason for the silent failures is historical: JavaScript did not have exceptions until ECMAScript 3. Since then, its designers have tried to avoid silent failures.
JavaScript was created in May 1995 in 10 days, by Brendan Eich.
There are two standards for JavaScript:
The language described by these standards is called ECMAScript, not JavaScript. A different name was chosen because Sun (now Oracle) had a trademark for the latter name. The “ECMA” in “ECMAScript” comes from the organization that hosts the primary standard.
The original name of that organization was ECMA, an acronym for European Computer Manufacturers Association. It was later changed to Ecma International (with “Ecma” being a proper name, not an acronym) because the organization’s activities had expanded beyond Europe. The initial all-caps acronym explains the spelling of ECMAScript.
Often, JavaScript and ECMAScript mean the same thing. Sometimes the following distinction is made:
Therefore, ECMAScript 6 is a version of the language (its 6th edition).
TC39 (Ecma Technical Committee 39) is the committee that evolves JavaScript. Its members are, strictly speaking, companies: Adobe, Apple, Facebook, Google, Microsoft, Mozilla, Opera, Twitter, and others. That is, companies that are usually competitors are working together on JavaScript.
Stage 0: ideation and exploration
Stage 1: designing a solution
Stage 2: refining the solution
Stage 2.7: testing and validation
Stage 3: gaining implementation experience
Stage 4: integration into draft specification and eventual inclusion in standard
One idea that occasionally comes up is to clean up JavaScript by removing old features and quirks. While the appeal of that idea is obvious, it has significant downsides.
Let’s assume we create a new version of JavaScript that is not backward compatible and fixes all of its flaws. As a result, we’d encounter the following problems:
So what is the solution? This is how JavaScript is evolved:
New versions are always completely backward compatible (but there may occasionally be minor, hardly noticeable clean-ups).
Old features aren’t removed or fixed. Instead, better versions of them are introduced. One example is declaring variables via let
and const
– which are improved versions of var
.
If aspects of the language are changed, it is done inside new syntactic constructs. That is, we opt in implicitly – for example:
yield
is only a keyword inside generators (which were introduced in ES6).
Stage 2.7 was added in late 2023, after stages 0, 1, 2, 3, 4 had already been in use for years.
const
immutable?";"In JavaScript, const
only means that the binding (the association between variable name and variable value) is immutable. The value itself may be mutable, like obj
in the following example.
const obj = { prop: 0 };
// Allowed: changing properties of `obj`
obj.prop = obj.prop + 1;
assert.equal(obj.prop, 1);
// Not allowed: assigning to `obj`
assert.throws(
() => { obj = {} },
{
name: 'TypeError',
message: 'Assignment to constant variable.',
}
);
The scope of a variable is the region of a program where it can be accessed. Consider the following code.
{ // // Scope A. Accessible: x
const x = 0;
assert.equal(x, 0);
{ // Scope B. Accessible: x, y
const y = 1;
assert.equal(x, 0);
assert.equal(y, 1);
{ // Scope C. Accessible: x, y, z
const z = 2;
assert.equal(x, 0);
assert.equal(y, 1);
assert.equal(z, 2);
}
}
}
// Outside. Not accessible: x, y, z
assert.throws(
() => console.log(x),
{
name: 'ReferenceError',
message: 'x is not defined',
}
);
x
.
Each variable is accessible in its direct scope and all scopes nested within that scope.
The variables declared via const
and let
are called block-scoped because their scopes are always the innermost surrounding blocks.
We can’t declare the same variable twice at the same level:
assert.throws(
() => {
eval('let x = 1; let x = 2;');
},
{
name: 'SyntaxError',
message: "Identifier 'x' has already been declared",
}
);
We can, however, nest a block and use the same variable name x
that we used outside the block:
const x = 1;
assert.equal(x, 1);
{
const x = 2;
assert.equal(x, 2);
}
assert.equal(x, 1);
Inside the block, the inner x
is the only accessible variable with that name. The inner x
is said to shadow the outer x
. Once we leave the block, we can access the old value again.
These two adjectives describe phenomena in programming languages:
Let’s look at examples of these two terms.
Static phenomenon: scopes of variables
Variable scopes are a static phenomenon. Consider the following code:
function f() {
const x = 3;
// ···
}
x
is statically (or lexically) scoped. That is, its scope is fixed and doesn’t change at runtime.
Variable scopes form a static tree (via static nesting).
Dynamic phenomenon: function calls
Function calls are a dynamic phenomenon. Consider the following code:
function g(x) {}
function h(y) {
if (Math.random()) g(y); // (A)
}
Whether or not the function call in line A happens, can only be decided at runtime.
Function calls form a dynamic tree (via dynamic calls).
JavaScript’s variable scopes are nested. They form a tree:
The root is also called the global scope. In web browsers, the only location where one is directly in that scope is at the top level of a script. The variables of the global scope are called global variables and accessible everywhere. There are two kinds of global variables:
Global declarative variables are normal variables:
const
, let
, and class declarations.
Global object variables are stored in properties of the so-called global object:
var
and function declarations.
globalThis
. It can be used to create, read, and delete global object variables.
Each module has its own variable scope that is a direct child of the global scope. Therefore, variables that exist at the top level of a module are not global.
The global variable globalThis
is the standard way of accessing the global object. It got its name from the fact that it has the same value as this
in global scope (script scope, not module scope).
The global object is now considered a mistake that JavaScript can’t get rid of, due to backward compatibility. It affects performance negatively and is generally confusing.
ECMAScript 6 introduced several features that make it easier to avoid the global object – for example:
const
, let
, and class declarations don’t create global object properties when used in global scope.
It is usually better to access global object variables via variables and not via properties of globalThis
. The former has always worked the same on all JavaScript platforms.
Tutorials on the web occasionally access global variables globVar
via window.globVar
. But the prefix “window.
” is not necessary and I recommend to omit it:
window.encodeURIComponent(str); // no
encodeURIComponent(str); // yes
Therefore, there are relatively few use cases for globalThis
– for example:
For JavaScript, TC39 needed to decide what happens if we access a constant in its direct scope, before its declaration:
{
console.log(x); // What happens here?
const x = 123;
}
Some possible approaches are:
undefined
.
Approach 1 was rejected because there is no precedent in the language for this approach. It would therefore not be intuitive to JavaScript programmers.
Approach 2 was rejected because then x
wouldn’t be a constant – it would have different values before and after its declaration.
let
uses the same approach 3 as const
, so that both work similarly and it’s easy to switch between them.
The time between entering the scope of a variable and executing its declaration is called the temporal dead zone (TDZ) of that variable:
ReferenceError
.
undefined
– if there is no initializer.
Scope | Activation | Duplicates | Global prop. | |
---|---|---|---|---|
const | Block | decl. (TDZ) | ✘ | ✘ |
let | Block | decl. (TDZ) | ✘ | ✘ |
function | Block (*) | start | ✔ | ✔ |
class | Block | decl. (TDZ) | ✘ | ✘ |
import | Module | start | ✘ | ✘ |
var | Function | start (partially) | ✔ | ✔ |
What is a closure then? A closure is a function plus a connection to the variables that exist at its “birth place”.
What is the point of keeping this connection? It provides the values for the free variables of the function – for example:
function funcFactory(value) {
return () => {
return value;
};
}
const func = funcFactory('abc');
assert.equal(func(), 'abc'); // (A)
funcFactory
returns a closure that is assigned to func
. Because func
has the connection to the variables at its birth place, it can still access the free variable value
when it is called in line A (even though it “escaped” its scope).
All functions in JavaScript are closures
Static scoping is supported via closures in JavaScript. Therefore, every function is a closure.
The specification makes an important distinction between values:
undefined
, null
, boolean
, number
, bigint
, string
, symbol
.
They don’t differ much – e.g., both have properties and can be used anywhere. These are the differences:
typeof
?";"x | typeof x |
---|---|
undefined | 'undefined' |
null | 'object' |
Boolean | 'boolean' |
Number | 'number' |
Bigint | 'bigint' |
String | 'string' |
Symbol | 'symbol' |
Function | 'function' |
All other objects | 'object' |
Number
and String
?";"Each primitive type (except for the types undefined
and null
) has an associated constructor function (think class):
Boolean
is associated with booleans.
Number
is associated with numbers.
String
is associated with strings.
Symbol
is associated with symbols.
Each of these functions plays several roles – for example, Number
:
We can use it as a function and convert values to numbers:
assert.equal(Number('123'), 123);
Number.prototype
provides the properties for numbers – for example, method .toString()
:
assert.equal((123).toString, Number.prototype.toString);
Number
is a namespace/container object for tool functions for numbers – for example:
assert.equal(Number.isInteger(123), true);
Lastly, we can also use Number
as a class and create number objects. These objects are different from real numbers and should be avoided. They virtually never show up in normal code. See the next subsection for more information.
For many operations, JavaScript automatically converts the operands/parameters if their types don’t fit. This kind of automatic conversion is called coercion.
For example, the multiplication operator coerces its operands to numbers:
> '7' * '3'
21
Many built-in functions coerce, too. For example, Number.parseInt()
coerces its parameter to a string before parsing it. That explains the following result:
> Number.parseInt(123.45)
123
The number 123.45
is converted to the string '123.45'
before it is parsed. Parsing stops before the first non-digit character, which is why the result is 123
.
JavaScript’s operators sometimes produce unintuitive results. With the following two rules, they are easier to understand:
As mentioned before, most operators only work with primitive values. If an operand is an object, it is usually coerced to a primitive value – for example:
> [1,2,3] + [4,5,6]
'1,2,34,5,6'
Why? The plus operator first coerces its operands to primitive values:
> String([1,2,3])
'1,2,3'
> String([4,5,6])
'4,5,6'
Next, it concatenates the two strings:
> '1,2,3' + '4,5,6'
'1,2,34,5,6'
+
) work";"The plus operator works as follows in JavaScript:
String mode lets us use +
to assemble strings:
> 'There are ' + 3 + ' items'
'There are 3 items'
Number mode means that if neither operand is a string (or an object that becomes a string) then everything is coerced to numbers:
> 4 + true
5
Number(true)
is 1
.
==
operator quirky?";"It often coerces in unexpected ways
If the operands have different types, loose equality often coerces. Some of those type coercions make sense:
> '123' == 123
true
> false == 0
true
Others less so:
> 0 == '\r\n\t ' // only whitespace
true
An object is coerced to a primitive value (only) if the other operand is primitive:
> [1, 2, 3] == '1,2,3'
true
> ['17'] == 17
true
Comparing with boolean values works differently from conversion via Boolean()
> Boolean(0)
false
> Boolean(2)
true
> 0 == false
true
> 2 == true
false
> 2 == false
false
> Boolean('')
false
> Boolean('abc')
true
> '' == false
true
> 'abc' == true
false
> 'abc' == false
false
===
and Object.is()
";"Object.is()
is even stricter than ===
– e.g.:
It considers NaN
, the error value for computations involving numbers, to be equal to itself:
> Object.is(NaN, NaN)
true
> NaN === NaN
false
It distinguishes a positive zero and a negative zero (the two are usually considered to be the same value, so this functionality is not that useful):
> Object.is(0, -0)
false
> 0 === -0
true
,
) work?";"The comma operator has two operands, evaluates both of them and returns the second one:
const result = (console.log('evaluated'), 'YES');
assert.equal(
result, 'YES'
);
Output:
evaluated
void
operator work?";"The void
operator evaluates its operand and returns undefined
:
const result = void console.log('evaluated');
assert.equal(
result, undefined
);
Output:
evaluated
undefined
and null
different?";"Both values are very similar and often used interchangeably. How they differ is therefore subtle. The language itself makes the following distinction:
undefined
means “not initialized” (e.g., a variable) or “not existing” (e.g., a property of an object).
null
means “the intentional absence of any object value” (a quote from the language specification).
Programmers sometimes make the following distinction:
undefined
is the non-value used by the language (when something is uninitialized, etc.).
null
means “explicitly switched off”. That is, it helps implement a type that comprises both meaningful values and a meta-value that stands for “no meaningful value”. Such a type is called option type or maybe type in functional programming.
undefined
occur in the language?";"Uninitialized variable myVar
:
let myVar;
assert.equal(myVar, undefined);
Parameter x
is not provided:
function func(x) {
return x;
}
assert.equal(func(), undefined);
Property .unknownProp
is missing:
const obj = {};
assert.equal(obj.unknownProp, undefined);
If we don’t explicitly specify the result of a function via a return
statement, JavaScript returns undefined
for us:
function func() {}
assert.equal(func(), undefined);
null
occur in the language?";"The prototype of an object is either an object or, at the end of a chain of prototypes, null
. Object.prototype
does not have a prototype:
> Object.getPrototypeOf(Object.prototype)
null
If we match a regular expression (such as /a/
) against a string (such as 'x'
), we either get an object with matching data (if matching was successful) or null
(if matching failed):
> /a/.exec('x')
null
The JSON data format does not support undefined
, only null
:
> JSON.stringify({a: undefined, b: null})
'{"b":null}'
??
) work?";"The nullish coalescing operator (??
) lets us use a default if a value is undefined
or null
:
value ?? defaultValue
value
is undefined
nor null
, defaultValue
is evaluated and the result is returned.
value
is returned.
Examples:
> undefined ?? 'default'
'default'
> null ?? 'default'
'default'
> false ?? 'default'
false
> 0 ?? 'default'
0
> '' ?? 'default'
''
> {} ?? 'default'
{}
??=
) work?";"The nullish coalescing assignment operator (??=
) assigns a default if a value is undefined
or null
:
value ??= defaultValue
value
is either undefined
or null
, defaultValue
is evaluated and assigned to value
.
Examples:
let value;
value = undefined;
value ??= 'DEFAULT';
assert.equal(
value, 'DEFAULT'
);
value = 0;
value ??= 'DEFAULT';
assert.equal(
value, 0
);
undefined
and null
are the only two JavaScript values where we get an exception if we try to read a property. To explore this phenomenon, let’s use the following function, which reads (“gets”) property .prop
and returns the result.
function getProp(x) {
return x.prop;
}
If we apply getProp()
to various values, we can see that it only fails for undefined
and null
:
> getProp(undefined)
TypeError: Cannot read properties of undefined (reading 'prop')
> getProp(null)
TypeError: Cannot read properties of null (reading 'prop')
> getProp(true)
undefined
> getProp({})
undefined
undefined
and null
?";"In Java (which inspired many aspects of JavaScript), initialization values depend on the static type of a variable:
null
.
int
variables are initialized with 0
.
JavaScript borrowed null
and uses it where objects are expected. It means “not an object”.
However, storage locations in JavaScript (variables, properties, etc.) can hold either primitive values or objects. They need an initialization value that means “neither an object nor a primitive value”. That’s why undefined
was introduced.
In most locations where JavaScript expects a boolean value, we can instead use an arbitrary value and JavaScript converts it to boolean before using it. Examples include:
if
statement
while
loop
do-while
loop
Consider the following if
statement:
if (value) {}
In many programming languages, this condition is equivalent to:
if (value === true) {}
However, in JavaScript, it is equivalent to:
if (Boolean(value) === true) {}
That is, JavaScript checks if value
is true
when converted to boolean. This kind of check is so common that the following names were introduced:
true
when converted to boolean.
false
when converted to boolean.
Each value is either truthy or falsy. This is an exhaustive list of falsy values:
undefined
null
false
0
, NaN
0n
''
All other values (including all objects) are truthy:
> Boolean('abc')
true
> Boolean([])
true
> Boolean({})
true
if
statement?";"The conditional operator is the expression version of the if
statement. Its syntax is:
«condition» ? «thenExpression» : «elseExpression»
It is evaluated as follows:
condition
is truthy, evaluate and return thenExpression
.
elseExpression
.
The conditional operator is also called ternary operator because it has three operands.
Examples:
> true ? 'yes' : 'no'
'yes'
> false ? 'yes' : 'no'
'no'
> '' ? 'yes' : 'no'
'no'
JavaScript has two binary logical operators:
x && y
)
x || y
)
Value-preservation means that operands are interpreted as booleans but returned unchanged:
> 12 || 'hello'
12
> 0 || 'hello'
'hello'
Short-circuiting means if the first operand already determines the result, then the second operand is not evaluated. The only other operator that delays evaluating its operands is the conditional operator. Usually, all operands are evaluated before performing an operation.
For example, logical And (&&
) does not evaluate its second operand if the first one is falsy:
const x = false && console.log('hello');
// No output
If the first operand is truthy, console.log()
is executed:
const x = true && console.log('hello');
Output:
hello
The expression !x
(“Not x
”) is evaluated as follows:
x
.
true
? Return false
.
true
.
Examples:
> !false
true
> !true
false
> !0
true
> !123
false
> !''
true
> !'abc'
false
JavaScript has two kinds of numeric values:
Several integer literals let us express integers with various bases:
// Binary (base 2)
assert.equal(0b11, 3); // ES6
// Octal (base 8)
assert.equal(0o10, 8); // ES6
// Decimal (base 10)
assert.equal(35, 35);
// Hexadecimal (base 16)
assert.equal(0xE7, 231);
e
mean in, e.g., 3e2
?";"Exponent: eN
means ×10N
> 3e2
300
> 3e-2
0.03
> 0.3e2
30
(7).toString(2)
7.0.toString(2)
7..toString(2)
7 .toString(2) // space before dot
These are three ways of converting values to numbers:
Number(value)
: has a descriptive name and is therefore recommended. Table summarizes how it works.
+value
: is equivalent to Number(value)
.
parseFloat(value)
: has quirks and should be avoided.
JavaScript has two numeric error values:
NaN
:
Number.isNaN()
. NaN
is not strictly equal to itself.
Infinity
:
Number.isFinite()
or by comparing via ===
.
JavaScript has floating point numbers that internally have the base 2. Therefore:
0.5 = 1/2
can be represented with base 2 because the denominator is already a power of 2.
0.25 = 1/4
can be represented with base 2 because the denominator is already a power of 2.
0.1 = 1/10
cannot be represented because the denominator cannot be converted to a power of 2.
0.2 = 2/10
cannot be represented because the denominator cannot be converted to a power of 2.
JavaScript (non-bigint) integers are simply floating point numbers without decimal fractions. But they are different in the following ways:
In some locations, only integers are allowed – e.g., the Array
constructor only accepts integers as lengths:
> new Array(1.1)
RangeError: Invalid array length
> new Array(1.0)
[,]
In some locations, numbers with fractions are coerced to numbers without fractions – e.g., the bitwise Or (|
) operation coerces its operands to 32-bit integers:
> 3.9 | 0
3
JavaScript has several constants and operations for working with integers:
> Math.log2(Number.MAX_SAFE_INTEGER)
53
> Number.isInteger(123.0)
true
> Number.parseInt('123')
123
Non-decimal integer literals can’t have fractions (the suffix .1
is interpreted as reading a property – whose name illegally starts with a digit):
0b1.1 // SyntaxError
0o7.1 // SyntaxError
0xF.1 // SyntaxError
Some JavaScript engines internally represent smaller integers differently – as real integers. For example, V8 does this for the following “small integer” ranges (source):
This is the range of integer numbers that are safe in JavaScript (53 bits plus a sign):
[–(253)+1, 253–1]
An integer is safe if it is represented by exactly one JavaScript number. Given that JavaScript numbers are encoded as a fraction multiplied by 2 to the power of an exponent, higher integers can also be represented, but then there are gaps between them.
For example (18014398509481984 is 254):
> 18014398509481983
18014398509481984
> 18014398509481984
18014398509481984
> 18014398509481985
18014398509481984
> 18014398509481986
18014398509481984
> 18014398509481987
18014398509481988
The following mathematical integers are therefore not safe:
&
), Or (|
)?";"Internally, JavaScript’s bitwise operators work with 32-bit integers. They produce their results in the following steps:
Before ECMAScript 2020, JavaScript handled integers as follows:
There only was a single type for floating point numbers and integers: 64-bit floating point numbers (IEEE 754 double precision).
Under the hood, most JavaScript engines transparently supported integers: If a number has no decimal digits and is within a certain range, it can internally be stored as a genuine integer. This representation is called small integer and usually fits into 32 bits. For example, the range of small integers on the 64-bit version of the V8 engine is from −231 to 231−1 (source).
JavaScript numbers could also represent integers beyond the small integer range, as floating point numbers. Here, the safe range is plus/minus 53 bits.
Sometimes, we need more than signed 53 bits – for example:
Bigint is a primitive data type for integers. Bigints don’t have a fixed storage size in bits; their sizes adapt to the integers they represent:
A bigint literal is a sequence of one or more digits, suffixed with an n
– for example:
123n
Operators such as -
and *
are overloaded and work with bigints:
> 123n * 456n
56088n
Bigints are primitive values. typeof
returns a distinct result for them:
> typeof 123n
'bigint'
Two concepts are crucial for understanding Unicode:
The first version of Unicode had 16-bit code points. Since then, the number of characters has grown considerably and the size of code points was extended to 21 bits. These 21 bits are partitioned in 17 planes, with 16 bits each:
Planes 1-16 are called supplementary planes or astral planes.
The Unicode encoding formats that are used in web development are: UTF-16 and UTF-8.
Source code internally: UTF-16
The ECMAScript specification internally represents source code as UTF-16.
Strings: UTF-16
The characters in JavaScript strings are based on UTF-16 code units:
> const smiley = '🙂';
> smiley.length
2
> smiley === '\uD83D\uDE42' // code units
true
Source code in files: UTF-8
HTML and JavaScript files are almost always encoded as UTF-8 now.
For example, this is how HTML files usually start now:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
···
A symbol is an abstract concept and part of written language:
const str1 = 'Don\'t say "goodbye"'; // string literal
const str2 = "Don't say \"goodbye\""; // string literals
assert.equal(
`As easy as ${123}!`, // template literal
'As easy as 123!',
);
String(v) | '' + v | v.toString() |
|
---|---|---|---|
undefined | 'undefined' | 'undefined' | TypeError |
null | 'null' | 'null' | TypeError |
true | 'true' | 'true' | 'true' |
123 | '123' | '123' | '123' |
123n | '123' | '123' | '123' |
"abc" | 'abc' | 'abc' | 'abc' |
Symbol() | 'Symbol()' | TypeError | 'Symbol()' |
{a:1} | '[object Object]' | '[object Object]' | '[object Object]' |
['a'] | 'a' | 'a' | 'a' |
{__proto__:null} | TypeError | TypeError | TypeError |
Symbol.prototype | TypeError | TypeError | TypeError |
() => {} | '() => {}' | '() => {}' | '() => {}' |
`${v}`
is equivalent to '' + v
.
The most common tricky values are:
'' + v
or `${v}`
.
null
prototypes (TypeError
)
Code units – code unit escape:
> '\u03B1\u03B2\u03B3'
'αβγ'
Code points - code point escape:
> '\u{1F642}'
'🙂'
Code points below 256 – ASCII escape:
> 'He\x6C\x6Co'
'Hello'
Operator +
assert.equal(3 + ' times ' + 4, '3 times 4');
Operator +=
let str = ''; // must be `let`!
str += 'Say it';
str += ' one more';
str += ' time';
assert.equal(str, 'Say it one more time');
Joining an Array
function getPackingList(isAbroad = false, days = 1) {
const items = [];
items.push('tooth brush');
if (isAbroad) {
items.push('passport');
}
if (days > 3) {
items.push('water bottle');
}
return items.join(', '); // (A)
}
assert.equal(
getPackingList(),
'tooth brush'
);
assert.equal(
getPackingList(true, 7),
'tooth brush, passport, water bottle'
);
The expression in line A is a tagged template. It is equivalent to invoking tagFunc()
with the arguments shown below line A.
function tagFunc(templateStrings, ...substitutions) {
return {templateStrings, substitutions};
}
const setting = 'dark mode';
const value = true;
assert.deepEqual(
tagFunc`Setting ${setting} is ${value}!`, // (A)
{
templateStrings: ['Setting ', ' is ', '!'],
substitutions: ['dark mode', true],
}
// tagFunc(['Setting ', ' is ', '!'], 'dark mode', true)
);
Option 1 – indenting the text and removing the indentation via a template tag:
import dedent from 'dedent';
function divDedented(text) {
return dedent`
<div>
${text}
</div>
`;
}
Option 2 – not indenting the text and removing leading and trailing whitespace via .trim()
:
function divDedented(text) {
return `
<div>
${text}
</div>
`.trim();
}
String.raw
do?";"Raw string literals are implemented via the tag function String.raw
. They are string literals where backslashes don’t do anything special (such as escaping characters, etc.):
assert.equal(
String.raw`\back`,
'\\back'
);
Symbols are primitive values:
They have to be categorized via typeof
:
const sym = Symbol();
assert.equal(typeof sym, 'symbol');
They can be property keys in objects:
const obj = {
[sym]: 123,
};
Even though symbols are primitives, they are also like objects in that values created by Symbol()
have unique identities and are not compared by value:
> Symbol() === Symbol()
false
Symbols as values for constants
const COLOR_RED = Symbol('Red');
const COLOR_ORANGE = Symbol('Orange');
const COLOR_YELLOW = Symbol('Yellow');
const COLOR_GREEN = Symbol('Green');
const COLOR_BLUE = Symbol('Blue');
const COLOR_VIOLET = Symbol('Violet');
function getComplement(color) {
switch (color) {
case COLOR_RED:
return COLOR_GREEN;
case COLOR_ORANGE:
return COLOR_BLUE;
case COLOR_YELLOW:
return COLOR_VIOLET;
case COLOR_GREEN:
return COLOR_RED;
case COLOR_BLUE:
return COLOR_ORANGE;
case COLOR_VIOLET:
return COLOR_YELLOW;
default:
throw new Exception('Unknown color: '+color);
}
}
assert.equal(getComplement(COLOR_YELLOW), COLOR_VIOLET);
Symbols as unique property keys
const specialMethod = Symbol('specialMethod');
const obj = {
_id: 'kf12oi',
[specialMethod]() { // (A)
return this._id;
}
};
assert.equal(obj[specialMethod](), 'kf12oi');
The keys of properties (fields) in objects are used at two levels:
The program operates at a base level. The keys at that level reflect the problem domain – the area in which a program solves a problem – for example:
ECMAScript and many libraries operate at a meta-level. They manage data and provide services that are not part of the problem domain. – for example:
The standard method .toString()
is used by ECMAScript when creating a string representation of an object (line A):
const point = {
x: 7,
y: 4,
toString() {
return `(${this.x}, ${this.y})`;
},
};
assert.equal(
String(point), '(7, 4)'); // (A)
.x
and .y
are base-level properties – they are used to solve the problem of computing with points. .toString()
is a meta-level property – it doesn’t have anything to do with the problem domain.
The standard ECMAScript method .toJSON()
can be used to customize how an object is converted to
const point = {
x: 7,
y: 4,
toJSON() {
return [this.x, this.y];
},
};
assert.equal(
JSON.stringify(point), '[7,4]');
.x
and .y
are base-level properties, .toJSON()
is a meta-level property.
The base level and the meta-level of a program must be independent: Base-level property keys should not be in conflict with meta-level property keys.
Symbols, used as property keys, help us here: Each symbol is unique and a symbol key never clashes with any other string or symbol key.
if
statement [ES1]
switch
statement [ES3]
while
loop [ES1]
do-while
loop [ES3]
for
loop [ES1]
for-of
loop [ES6]
for-await-of
loop [ES2018]
for-in
loop [ES1]
There are two versions of break
:
The former version works inside the following statements: while
, do-while
, for
, for-of
, for-await-of
, for-in
and switch
. It immediately leaves the current statement:
for (const x of ['a', 'b', 'c']) {
console.log(x);
if (x === 'b') break;
console.log('---')
}
Output:
a
---
b
break
plus label: leaving any labeled statement
break
with an operand works everywhere. Its operand is a label. Labels can be put in front of any statement, including blocks. break myLabel
leaves the statement whose label is myLabel
:
myLabel: { // label
if (condition) break myLabel; // labeled break
// ···
}
continue
only works inside while
, do-while
, for
, for-of
, for-await-of
, and for-in
. It immediately leaves the current loop iteration and continues with the next one – for example:
const lines = [
'Normal line',
'# Comment',
'Another normal line',
];
for (const line of lines) {
if (line.startsWith('#')) continue;
console.log(line);
}
Output:
Normal line
Another normal line
if
, while
, and do-while
have conditions that are, in principle, boolean. However, a condition only has to be truthy (true
if coerced to boolean) in order to be accepted. In other words, the following two control flow statements are equivalent:
if (value) {}
if (Boolean(value) === true) {}
for-await-of
, for-of
, .forEach()
, for
and for-in
?";"for-await-of
.
for-of
. Available in ES6+.
.forEach()
.
for
loop to loop over an Array.
for-in
to loop over an Array.
try
, catch
and finally
clauses do";"try {
«try_statements»
} catch (error) {
«catch_statements»
} finally {
«finally_statements»
}
The try
block can be considered the body of the statement. This is where we execute the regular code.
If an exception is thrown somewhere inside the try
block (which may happen deeply nested inside the tree of function/method calls) then execution switches to the catch
clause where the parameter refers to the exception. After that, execution normally continues after the try
statement.
The code inside the finally
clause is always executed at the end of a try
statement – no matter what happens in the try
block or the catch
clause.
Error
and its subclasses have?";"This is what Error
’s instance properties and constructor look like:
class Error {
// Actually a prototype data property
get name(): string {
return 'Error';
}
// Instance properties
message: string;
cause?: unknown; // ES2022
stack: string; // non-standard but widely supported
constructor(
message: string = '',
options?: ErrorOptions // ES2022
) {}
}
interface ErrorOptions {
cause?: unknown; // ES2022
}
The constructor has two parameters:
message
specifies an error message.
options
was introduced in ECMAScript 2022. It contains an object where one property is currently supported:
.cause
specifies which exception (if any) caused the current error.
JavaScript has two categories of functions:
An ordinary function can play several roles:
A specialized function can only play one of those roles – for example:
Specialized functions were added to the language in ECMAScript 6.
Parameter default values specify the value to use if a parameter has not been provided – for example:
function f(x, y=0) {
return [x, y];
}
assert.deepEqual(f(1), [1, 0]);
assert.deepEqual(f(), [undefined, 0]);
undefined
also triggers the default value:
assert.deepEqual(
f(undefined, undefined),
[undefined, 0]
);
A rest parameter is declared by prefixing an identifier with three dots (...
). During a function or method call, it receives an Array with all remaining arguments. If there are no extra arguments at the end, it is an empty Array – for example:
function f(x, ...y) {
return [x, y];
}
assert.deepEqual(
f('a', 'b', 'c'), ['a', ['b', 'c']]
);
assert.deepEqual(
f(), [undefined, []]
);
JavaScript doesn’t have real named parameters. The official way of simulating them is via object literals:
function selectEntries({start=0, end=-1, step=1}) {
return {start, end, step};
}
If we put three dots (...
) in front of the argument of a function call, then we spread it. That means that the argument must be an iterable object and the iterated values all become arguments. In other words, a single argument is expanded into multiple arguments – for example:
function func(x, y) {
console.log(x);
console.log(y);
}
const someIterable = ['a', 'b'];
func(...someIterable);
// same as func('a', 'b')
Output:
a
b
.call()
work?";"Each function someFunc
has the following method:
someFunc.call(thisValue, arg1, arg2, arg3);
This method invocation is loosely equivalent to the following function call:
someFunc(arg1, arg2, arg3);
However, with .call()
, we can also specify a value for the implicit parameter this
. In other words: .call()
makes the implicit parameter this
explicit.
.apply()
work?";"Each function someFunc
has the following method:
someFunc.apply(thisValue, [arg1, arg2, arg3]);
This method invocation is loosely equivalent to the following function call (which uses spreading):
someFunc(...[arg1, arg2, arg3]);
However, with .apply()
, we can also specify a value for the implicit parameter this
.
.bind()
work?";".bind()
is another method of function objects. This method is invoked as follows:
const boundFunc = someFunc.bind(thisValue, arg1, arg2);
.bind()
returns a new function boundFunc()
. Calling that function invokes someFunc()
with this
set to thisValue
and these parameters: arg1
, arg2
, followed by the parameters of boundFunc()
.
That is, the following two function calls are equivalent:
boundFunc('a', 'b')
someFunc.call(thisValue, arg1, arg2, 'a', 'b')
eval
directly and indirectly?";"There are two ways of invoking eval()
:
“Not via a function call” means “anything that looks different than eval(···)
”:
eval.call(undefined, '···')
(uses method .call()
of functions)
eval?.('···')
(uses optional chaining)
(0, eval)('···')
(uses the comma operator)
globalThis.eval('···')
const e = eval; e('···')
new Function()
work?";"new Function()
creates a function object and is invoked as follows:
const func = new Function('«param_1»', ···, '«param_n»', '«func_body»');
The previous statement is equivalent to the next statement. Note that «param_1»
, etc., are not inside string literals, anymore.
const func = function («param_1», ···, «param_n») {
«func_body»
};
Usage | Runs on | Loaded | Filename ext. | |
---|---|---|---|---|
Script | Legacy | browsers | async | .js |
CommonJS module | Decreasing | servers | sync | .js .cjs |
AMD module | Legacy | browsers | async | .js |
ECMAScript module | Modern | browsers, servers | async | .js .mjs |
default
, along with special syntax for importing it.
Namespace imports are an alternative to named imports. If we namespace-import a module, it becomes an object whose properties are the named exports. This is what main.mjs
looks like if we use a namespace import:
import * as myMath from './lib/my-math.mjs';
assert.equal(myMath.square(3), 9);
assert.deepEqual(
Object.keys(myMath), ['LIGHT_SPEED', 'square']
);
Re-exporting turns another module’s exports into exports of the current module:
//===== library.mjs =====
// Named re-export [ES6]
export {internalFunc as func, INTERNAL_DEF as DEF} from './internal.mjs';
// Wildcard re-export [ES6]
export * from './internal.mjs';
// Namespace re-export [ES2020]
export * as ns from './internal.mjs';
In the JavaScripte ecosystem, a package is a way of organizing software projects: It is a directory with a standardized layout. A package can contain all kinds of files - for example:
A package can depend on other packages (which are called its dependencies):
The main way of publishing a package is to upload it to a package registry – an online software repository. Two popular public registries are:
Companies can also host their own private registries.
A package manager is a command line tool that downloads packages from a registry (or other sources) and installs them as shell scripts and/or as dependencies. The most popular package manager is called npm and comes bundled with Node.js. Its name originally stood for “Node Package Manager”. Later, when npm and the npm registry were used not only for Node.js packages, that meaning was changed to “npm is not a package manager” ([source](https://en.wikipedia.org/wiki/Npm_(software)#Acronym)). There are other popular package managers such as jsr, vlt, pnpm and yarn. All of these package managers support either or both of the npm registry and JSR.
Each package has a name. There are two kinds of names:
Global names are unique across the whole registry. These are two examples:
minimatch
mocha
Scoped names consist of two parts: A scope and a name. Scopes are globally unique, names are unique per scope. These are two examples:
@babel/core
@rauschma/iterable
The scope starts with an @
symbol and is separated from the name with a slash.
Once a package my-package
is fully installed, it almost always looks like this:
my-package/
package.json
node_modules/
[More files]
What are the purposes of these file system entries?
package.json
is a file every package must have:
node_modules/
is a directory into which the dependencies of the package are installed. Each dependency also has a node_modules
folder with its dependencies, etc. The result is a tree of dependencies.
There are three kinds of module specifiers:
Absolute specifiers are full URLs – for example:
'https://www.unpkg.com/browse/yargs@17.3.1/browser.mjs'
'file:///opt/nodejs/config.mjs'
Absolute specifiers are mostly used to access libraries that are directly hosted on the web.
Relative specifiers are relative URLs (starting with '/'
, './'
or '../'
) – for example:
'./sibling-module.js'
'../module-in-parent-dir.mjs'
'../../dir/other-module.js'
Every module has a URL whose protocol depends on its location (file:
, https:
, etc.). If it uses a relative specifier, JavaScript turns that specifier into a full URL by resolving it against the module’s URL.
Relative specifiers are mostly used to access other modules within the same code base.
Bare specifiers are paths (without protocol and domain) that start with neither slashes nor dots. They begin with the names of packages. Those names can optionally be followed by subpaths:
'some-package'
'some-package/sync'
'some-package/util/files/path-tools.js'
Bare specifiers can also refer to packages with scoped names:
'@some-scope/scoped-name'
'@some-scope/scoped-name/async'
'@some-scope/scoped-name/dir/some-module.mjs'
Each bare specifier refers to exactly one module inside a package; if it has no subpath, it refers to the designated “main” module of its package.
A bare specifier is never used directly but always resolved – translated to an absolute specifier. How resolution works depends on the platform.
import.meta.url
?";"The most important property of import.meta
is .url
which contains a string with the URL of the current module’s file – for example:
'https://example.com/code/main.mjs'
This is how we get a URL
instance that points to a file data.txt
that sits next to the current module:
const urlOfData = new URL('data.txt', import.meta.url);
Limitations of the static import
statement:
if
statement.
The import()
operator doesn’t have the limitations of import
statements. It looks like this:
const namespaceObject = await import(moduleSpecifierStr);
console.log(namespaceObject.namedExport);
This operator is used like a function, receives a string with a module specifier and returns a Promise that resolves to a namespace object. The properties of that object are the exports of the imported module.
await
?";"We can use the await
operator at the top level of a module. If we do that, the module becomes asynchronous and works differently. Thankfully, we don’t usually see that as programmers because it is handled transparently by the language.
The motivating use case for import attributes was importing JSON data as a module. That looks as follows:
import configData from './config-data.json' with { type: 'json' };
type
is an import attribute (more on the syntax soon).
To support import attributes, dynamic imports get a second parameter – an object with configuration data:
const configData = await import(
'./config-data.json', { with: { type: 'json' } }
);
Given a web platform feature X:
There are two ways of using objects in JavaScript:
Fixed-layout objects: Used this way, objects work like records in databases. They have a fixed number of properties, whose keys are known at development time. Their values generally have different types.
const fixedLayoutObject = {
product: 'carrot',
quantity: 4,
};
Dictionary objects: Used this way, objects work like lookup tables or maps. They have a variable number of properties, whose keys are not known at development time. All of their values have the same type.
const dictionaryObject = {
['one']: 1,
['two']: 2,
};
Note that the two ways can also be mixed: Some objects are both fixed-layout objects and dictionary objects.
Accessors are methods that are invoked by accessing a property. It consists of either or both of:
Getters
A getter is created by prefixing a method definition with the modifier get
:
const jane = {
first: 'Jane',
last: 'Doe',
get full() {
return `${this.first} ${this.last}`;
},
};
assert.equal(jane.full, 'Jane Doe');
jane.first = 'John';
assert.equal(jane.full, 'John Doe');
Setters
A setter is created by prefixing a method definition with the modifier set
:
const jane = {
first: 'Jane',
last: 'Doe',
set full(fullName) {
const parts = fullName.split(' ');
this.first = parts[0];
this.last = parts[1];
},
};
jane.full = 'Richard Roe';
assert.equal(jane.first, 'Richard');
assert.equal(jane.last, 'Roe');
Inside an object literal, a spread property adds the properties of another object to the current one:
const obj1 = {a: 1, b: 2};
const obj2 = {c: 3};
assert.deepEqual(
{...obj1, ...obj2, d: 4},
{a: 1, b: 2, c: 3, d: 4}
);
Use cases include:
structuredClone()
copy?";".call()
to explain how this
works in method invocations";"Methods are functions and functions have methods themselves. One of those methods is .call()
. Let’s look at an example to understand how this method works.
In the previous section, there was this method invocation:
obj.someMethod('a', 'b')
This invocation is equivalent to:
obj.someMethod.call(obj, 'a', 'b');
Which is also equivalent to:
const func = obj.someMethod;
func.call(obj, 'a', 'b');
.call()
makes the normally implicit parameter this
explicit: When invoking a function via .call()
, the first parameter is this
, followed by the regular (explicit) function parameters.
As an aside, this means that there are actually two different dot operators:
obj.prop
obj.prop()
They are different in that (2) is not just (1) followed by the function call operator ()
. Instead, (2) additionally provides a value for this
.
Problem – invoking func()
in line A does not provide the proper this
:
const jane = {
first: 'Jane',
says(text) {
return `${this.first} says “${text}”`;
},
};
const func = jane.says; // extract the method
assert.throws(
() => func('hello'), // (A)
{
name: 'TypeError',
message: "Cannot read properties of undefined (reading 'first')",
}
);
How do we fix this? We need to use .bind()
to extract method .says()
:
const func2 = jane.says.bind(jane);
assert.equal(func2('hello'), 'Jane says “hello”');
The .bind()
ensures that this
is always jane
when we call func()
.
We can also use arrow functions to extract methods:
const func3 = text => jane.says(text);
assert.equal(func3('hello'), 'Jane says “hello”');
this
?";"Use arrow functions as anonymous inline functions. They don’t have this
as an implicit parameter and don’t shadow it.
For named stand-alone function declarations we can either use arrow functions or function declarations. If we do the latter, we have to make sure this
isn’t mentioned in their bodies.
The following kinds of optional chaining operations exist:
obj?.prop // optional fixed property getting
obj?.[«expr»] // optional dynamic property getting
func?.(«arg0», «arg1», ···) // optional function or method call
The rough idea is:
undefined
nor null
, then perform the operation after the question mark.
undefined
.
enumerable | non-e. | string | symbol | |
---|---|---|---|---|
Object.keys() | ✔ | ✔ | ||
Object.getOwnPropertyNames() | ✔ | ✔ | ✔ | |
Object.getOwnPropertySymbols() | ✔ | ✔ | ✔ | |
Reflect.ownKeys() | ✔ | ✔ | ✔ | ✔ |
Own (non-inherited) properties of objects are always listed in the following order:
If we use plain objects (created via object literals) as dictionaries, we have to look out for two pitfalls.
Pitfall 1: getting inherited properties
The following dictionary object should be empty. However, we get a value (and not undefined
) if we read an inherited property:
const dict = {};
assert.equal(
typeof dict['toString'], 'function'
);
dict
is an instance of Object
and inherits .toString()
from Object.prototype
.
Pitfall 2: checking if a property exists
If we use the in
operator to check if a property exists, we again detect inherited properties:
const dict = {};
assert.equal(
'toString' in dict, true
);
As an aside: Object.hasOwn()
does not have this pitfall. As its name indicates, it only considers own (non-inherited) properties:
const dict = {};
assert.equal(
Object.hasOwn(dict, 'toString'), false
);
Pitfall 3: property key '__proto__'
We can’t use the property key '__proto__'
because it has special powers (it sets the prototype of the object):
const dict = {};
dict['__proto__'] = 123;
// No property was added to dict:
assert.deepEqual(
Object.keys(dict), []
);
Objects with null
prototypes as dictionaries
Maps are usually the best choice when it comes to dictionaries: They have a convenient method-based API and support keys beyond strings and symbols.
However, objects with null
prototypes are also decent dictionaries and don’t have the pitfalls we just encountered:
const dict = Object.create(null);
// No inherited properties
assert.equal(
dict['toString'], undefined
);
assert.equal(
'toString' in dict, false
);
// No special behavior with key '__proto__'
dict['__proto__'] = true;
assert.deepEqual(
Object.keys(dict), ['__proto__']
);
We avoided the pitfalls:
in
operator.
Object.prototype.__proto__
is switched off because Object.prototype
is not in the prototype chain of dict
.
Just as objects are composed of properties, properties are composed of attributes. There are two kinds of properties and they are characterized by their attributes:
value
holds any JavaScript value.
get
, the latter in the attribute set
.
Additionally, there are attributes that both kinds of properties have. The following table lists all attributes and their default values.
Kind of property | Name and type of attribute | Default value |
---|---|---|
All properties | configurable: boolean | false |
enumerable: boolean | false |
|
Data property | value: any | undefined |
writable: boolean | false |
|
Accessor property | get: (this: any) => any | undefined |
set: (this: any, v: any) => void | undefined |
JavaScript has three levels of protecting objects:
Object.preventExtensions(obj)
Object.isExtensible(obj)
Object.seal(obj)
Object.isSealed(obj)
Object.freeze(obj)
Object.isFrozen(obj)
Private slots have unique keys that are similar to symbols. Consider the following class from earlier:
class MyClass {
#instancePrivateField = 1;
instanceProperty = 2;
getInstanceValues() {
return [
this.#instancePrivateField,
this.instanceProperty,
];
}
}
Internally, the private field of MyClass
is handled roughly like this:
let MyClass;
{ // Scope of the body of the class
const instancePrivateFieldKey = Symbol();
MyClass = class {
__PrivateElements__ = new Map([
[instancePrivateFieldKey, 1],
]);
instanceProperty = 2;
getInstanceValues() {
return [
this.__PrivateElements__.get(instancePrivateFieldKey),
this.instanceProperty,
];
}
}
}
The value of instancePrivateFieldKey
is called a private name. We can’t use private names directly in JavaScript, we can only use them indirectly, via the fixed identifiers of private fields, private methods, and private accessors. Where the fixed identifiers of public slots (such as getInstanceValues
) are interpreted as string keys, the fixed identifiers of private slots (such as #instancePrivateField
) refer to private names (similarly to how variable names refer to values).
Sup
, a subclass Sub
and s = new Sub()
?";"s
is Sub.prototype
whose prototype is Super.prototype
Sub
is Super
.
class C {
static staticMethod() {}
method() {}
field = 1;
}
C.staticMethod
C.prototype.method
.field
is a property of instances
v instanceof C
actually check?";"How does instanceof
determine if a value x
is an instance of a class C
? Note that “instance of C
” means direct instance of C
or direct instance of a subclass of C
.
instanceof
checks if C.prototype
is in the prototype chain of x
. That is, the following two expressions are equivalent:
x instanceof C
C.prototype.isPrototypeOf(x)