Published on

Notes on JavaScript

Authors

JavaScript Introduction

  • Has its own specification - ECMAScript
  • JS Engine Codenames
    • V8 - Chrome & Opera
    • Spider Monkey - Firefox
    • Chakra - IE
    • Nitro & SquirrelFish - Safari
  • How JS Engine Works
    1. Parses the script
    2. Compiles to machine code
    3. Runs machine code
  • Safety & Abilities
    • It's safe: no low-level access.
    • Abilities in the browser are limited for user's safety.
    • No direct access to OS functions.
    • JS on one page has no access to other pages.
    • Ability to access data from other sites is crippled.
    • Above limits do not exist outside the browser.
  • Transpilers
    • CoffeeScript
    • TypeScript
    • Flow
    • Dart
    • Brython
    • Kotlin
  • The ECMA-262 Specification

Fundamentals

  • Script tag can be inserted anywhere.

  • HTML4 <script> tag must have type attribute.

  • HTML5 does not require type or language attributes.

  • If src is set, the script contents are ignored.

  • Automatic semicolon insertion: line break is interpreted as implicit semicolon.

  • JS does not assume semicolon before a square bracket ;[].

  • It is recommended to use semicolons.

  • Nested comments are not supported inside /* */.

  • After 2009, ES5 added new features. To enable them, had to use "use strict" for modern behavior.

  • "strict mode" must be at the top, or it won't be enabled.

  • Browser developer consoles do not enable "strict mode" by default.

  • Reliable way to use "strict mode" is to use it inside a wrapper:

    ;(() => {
      'use strict'
      // other code
    })()
    
  • No need to explicitly write "use strict" if we use modern JS like classes or modules.

  • Use "use strict" until you are coding in all classes and modules.

Variables

  • It's a named storage for data.
  • Types
    1. let
    2. var
    3. const
  • Declaring twice triggers an error (except for var).
  • Name must contain only letters, digits, or the symbols $ and _.
  • The first character for the name must not be a digit.
  • Case (uppercase/lowercase) matters.
  • Non-Latin letters are allowed, but not recommended.
  • It's an international convention to use English in variable names.
  • When using "use strict", let/var/const is required.
  • Can't reassign a const.
  • Constants known prior to execution are NAMED_WITH_CAPS_N_UNDERSCORES.
  • Capital named constants are only used as aliases for "hard-coded" values.
  • A variable name should have a clean, obvious meaning, describing the data that it stores.
  • An extra variable is good, not evil.

Data Types

  • Primitive Types
    1. Number
      • Integers
      • Floats
      • Infinity
      • -Infinity
      • NaN
        • Represents computational error. It's a result of incorrect or an undefined mathematical operation.
        • NaN is sticky. Any further operation on NaN returns NaN.
    2. BigInt
      • The "n" at the end means it's a BigInt.
      • Rarely needed.
      • BigInt is supported in Chrome/Firefox/Edge/Safari but not in IE.
    3. String
      • Three types:
        • Double quotes (Simple quotes)
        • Single quotes (Simple quotes)
        • Back-ticks (Extended functionality quotes)
      • The expression inside ${...} is evaluated and the result becomes a part of the string.
      • There are no character types (like in C++ or Java, a single character is also a string of length 1).
    4. Boolean
      • True
      • False
    5. The "null" value
      • Represents "nothing", "empty" or "value unknown".
    6. The "undefined" value
      • Its meaning is – "value is not assigned".
    7. Symbols
      • Used to create unique identities for Objects.
  • Special Type
    1. Objects
      • Objects are used to store collections of data and more complex entities.

The typeof operator

  • Can be used like: typeof x
  • Can be used like: typeof(x)
  • Returns a lowercase string with the name of the type, like "string".
  • For null returns "object" (known error in JS).

Type Conversion

  • alert automatically converts any value to a string to show it.
  • Mathematical operations convert values to numbers.
  • String conversion happens when we need a string form of a value.
  • Numeric conversion happens in mathematical functions and expressions automatically.
    • alert("5" / "2") // converts to a number
  • If a string is not a valid number, the result of such conversion is NaN (e.g., Number("some string") = NaN).
ValueNumber(x) Becomes
undefinedNaN
null0
true1
false0
stringFirst trimmed, if empty then 0, else number. Error gives NaN.
  • alert(Number("123")) // 123
  • alert(Number("123z")) // NaN
  • Boolean conversion: Values that are intuitively "empty", like 0, an empty string, null, undefined, and NaN, become false.
  • In JS, non-empty strings are always true - Boolean("0") = true = Boolean(" ").
  • Boolean of 0, null, undefined, NaN, "" => false. Anything else = true.

String Concatenation with binary +

  • Usually, the + operation sums numbers.
  • But if the binary + is applied to strings, it merges (concatenates) them.
  • If any of the operands is a string, then the other one is converted to a string too.
  • It doesn't matter whether the first operand is a string or the second one.
  • Operators work one after another (e.g., 2 + 2 + "1" = "41").
  • Other arithmetic operators work only with numbers and ALWAYS convert their operands to numbers.
  • If the operand is not a number, the unary plus converts it to a number.
  • Unary plus (+x) works similar to Number(x).
  • Unary plus is applied first; they convert strings to numbers, and then the binary plus sums them up.

Assignments

  • Chained assignments evaluate from right to left.

  • Modify-and-assign operators exist for all arithmetical and bitwise operators (/=, -=, etc).

  • Increment/decrement can only be applied to variables. Trying to use it on a value like 5++ will give an error.

  • ++ or -- operators can be placed either before or after a variable.

    • When the operator goes after the variable, it is in postfix form.
    • Similarly, prefix form.
    • The prefix value returns the new value, while postfix form returns the old value.
  • The comma operator is one of the rarest and most unusual operators.

    let a = (1 + 2, 3 + 4)
    alert(a) // Shows 7. (1+2) is calculated and result thrown away, then (3+4) is calculated and returned.
    
    • The first expression 1+2 is calculated, and its result is thrown away.
    • Then 3+4 is calculated, and its result is returned as the result.
  • The comma has a very low precedence, lower than =.

Comparisons

  • JS uses so-called dictionary or "lexicographical" order for string comparison.
  • String comparison uses Unicode order, not real dictionary order.
  • When comparing values of different types, JS converts the values to numbers.
  • A regular equality check == has a problem: it cannot differentiate 0 from false.
  • A strict equality operation === checks the equality without type conversion.
    • alert(null === undefined); // false
    • alert(null == undefined); // true
  • For math and other comparisons (>, <, <=, >=), null/undefined are converted to numbers: null becomes 0, while undefined becomes NaN.
  • Strange result null vs 0
    • null > 0; // false
    • null == 0; // false
    • null >= 0; // true
  • Comparison converts null to a number, treating it as 0.

Incomparable undefined

  • undefined > 0; // false
  • undefined < 0; // false
  • undefined == 0; // false
  • undefined gets converted to NaN, and NaN is a special numeric value which returns false for all comparisons.
  • The equality check (undefined == 0) returns false because undefined only equals null, undefined and no other value.
  • Don't use comparison with a variable which may be null/undefined.
  • When values of different types are compared, they get converted to numbers (for == and not for ===).
  • Be careful when using comparisons like > or < with variables that can occasionally be null/undefined.
  • Checking for null/undefined separately is a good idea.
  • The conditional operator ? has low precedence, so it executes after the comparison >.

Logical Operators

  • Four in number:
    1. || (OR)
    2. && (AND)
    3. ! (NOT)
    4. ?? (Nullish Coalescing)
  • If an operand is not a Boolean, it's converted to a boolean for the evaluation.
  • OR (||) finds the first truthy value.
  • OR (||) operands convert to boolean; if the result is true, stops and returns the original value of that operand.
  • Short-circuit evaluation (xx || yy || zz) returns the first truthy value or the last one if no truthy values.
  • AND (&&) finds the first falsey value.
  • AND (&&): For each operand, converts it to a boolean. If the result is false, stops and returns the original value of that operand.
  • If all operands have been evaluated, returns the last operand.
  • AND returns the first falsey value or the last value if none were found.
  • Precedence of AND (&&) is higher than OR (||).
  • Don't replace if with || or &&.
  • A double NOT (!!)is sometimes used to convert a value to boolean.
  • a ?? b
    • If a is defined, then a.
    • If a isn't defined, then b.
  • ?? treats null and undefined similarly.
  • ?? Returns the first argument if it's not null/undefined. Otherwise, the second one.
  • The important difference between ?? And || is:
    • || returns the first truthy value.
    • ?? Returns the first defined value.
  • The precedence of the ?? (5) Operator is about the same as || (6), just a bit lower.
  • Just like ||, ?? is evaluated before = and ?, but after most operations such as +, *.
let area = (height ?? 100) * (width ?? 50)
  • It is forbidden to use ?? with || and && without explicit parenthesis.
  • ?? Provides a short way to choose the first "defined" value from a list.

Loops

  • Three types:

    • while - The condition is checked before each iteration.
    • do...while - The condition is checked after each iteration.
    • for (;;)- The condition is checked before each iteration, additional settings are available.
  • Curly braces are not required for a single-line body.

  • do...while loop is used to execute at least once.

  • for (begin; condition; step) { body }

  • Working of for loop

    • (If condition) run body and run step
    • (If condition) run body and run step
    • (If condition) run body and run step
    • Any part of the for can be skipped.
  • In for, two semicolons must be present, otherwise it's an error.

  • We can force the exit at any time, using the special break directive.

  • The continue is a lighter version of break.

  • Use no break/continue to the right side of ?.

  • To break out of multiple nested loops, we need to use break <labelName>.

    labelName: for (;;) {
      // ...
      break labelName
    }
    
  • continue directive can also be used with a label.

  • Labels do not allow to "jump" anywhere.

  • A break directive must be inside a code block. Technically, any labelled code block will do.

    label: {
      some code;
      break label;
    }
    
  • Although 99.9% of the time break is used inside loops.

  • A continue is only possible from inside a loop.

The "switch" statement

  • switch(x) { ... cases ... }
  • The value of x is checked for strict equality.
  • If the equality is found, switch starts to execute the code starting from the corresponding case until the nearest break.
  • If there is no break, then the execution continues with the next case without any checks.
  • Any expression can be a switch/case argument.
  • Several variants of case which share the same code can be grouped.
  • Equality check is always strict.

Function

  • Functions are the main "building blocks" of the program.
  • Variables declared outside of any function are called global.
  • It's a good practice to minimize the use of global variables.
  • A default parameter is evaluated every time the function is called without the respective parameter.
  • When the execution reaches the "return" directive, the function stops.
  • A function with an empty return or without it, returns undefined.
  • Never add a newline between return and the value.
  • If we want the returned expression to wrap across multiple lines, we should start it at the same line as return. Or at least put the opening parenthesis there.
  • It's a widespread practice to start a function name with a verbal prefix.
  • A function should do exactly what is suggested by its name, no more (one function = one action).
  • A function may access outer variables. But it works only from inside out. The code outside of the function doesn't see its local variables.
  • A function can return a value. If it doesn't, then its result is undefined.
  • A semicolon is recommended at the end of statements, no matter what the value is.
  • Function expression vs function declaration
    • A more subtle difference is when a function is created by the JS Engine.
    • A function expression is created when the execution reaches it, and it is usable only from that moment.
    • A function Declaration can be called earlier than it is defined.
    • In strict mode, when a Function Declaration is within a code block, it's visible everywhere inside that block, but not outside of it.
    • Declaration syntax gives more freedom in how to organize our code.
    • Function Expressions are created when the execution flow reaches them.
    • Function Declaration is preferable.

Objects

  • There are eight data types in JS. Seven of them are called primitives.

  • Two syntaxes:

    • let user = new Object(); // "object constructor" syntax
    • let user = {}; // "object literal" syntax
  • Trailing or hanging comma is good.

  • Square brackets like user["likes birds"] = true;

  • We can use square brackets in object literals:

    let bag = {
      [fruit + 'computers']: 5,
    }
    
  • Square brackets are much more powerful than dot notation.

  • Language restricted words such as "let", "for", "return" can be used for an object property; there is no restriction.

  • Object properties can be any strings or symbols.

  • Other types are automatically converted into strings.

  • A number 0 becomes a string "0" when used as a property key: obj[0] === obj["0"].

  • The property __proto__ cannot be set to a non-object.

  • Reading a non-existing property just returns undefined.

  • Situations like when obj.prop = undefined, it works right.

  • A special operator "in" is used to check if a key exists in an object ("key" in object).

  • Left side of the "in" must be a property name.

  • To walk over all keys of an object, there exists a special form of the loop: for...in.

    for (let prop in object) { ... }
    
  • Object properties are ordered in a special fashion: Integer properties are sorted, others appear in creation order.

  • "Integer Properties" means a string that can be converted to and from an integer without change.

  • "49" is an integer key but not "+49" or "1.2".

  • To delete a property: delete obj.prop.

  • Objects are stored and copied "by reference", whereas primitive values (string, numbers, booleans, etc.) are always copied "as a whole value".

  • A variable assigned to an object stores not the object itself, but its "address in memory" – in other words, "a reference" to it.

  • When an object variable is copied, the reference is copied, but the object itself is not duplicated.

Cloning and Merging, Object.assign

  • Copying an object variable creates one more reference to the same object.

  • There's no built-in method for deep cloning in JS.

  • We need to create a new object and replicate the structure of the existing one by iterating over its properties and copying them on the primitive level.

    for (let key in obj) {
      clone[key] = user[key]
    }
    
  • Object.assign(dest, [src1, src2, src3, ...])

  • Object.assign can be used to:

    1. Modify an existing object.
    2. Create a new Object.
  • If the copied property name already exists, it gets overwritten.

    let clone = Object.assign({}, user)
    
  • A third way is to use spread syntax: let clone = { ...user };

  • Object.assign() creates a shallow copy; for a deep copy, we need to use a library like _.cloneDeep(obj).

Garbage collection

  • The main concept of memory management in JavaScript is reachability.
  • Reachable values are those that are accessible or usable somehow. They are guaranteed to be stored in memory.
  • Outgoing references do not matter. Only incoming ones can make an object reachable.
  • The basic garbage collection algorithm is called mark-and-sweep.
  • The garbage collector tries to run only when the CPU is idle, to reduce the possible effect on execution.
  • Garbage collection is performed automatically. We cannot force or prevent it.
  • Being referenced is not the same as being reachable from root.

Object methods, this

  • A function that is a property of an object is called its method.
  • We can omit "function" and can just write sayHi() inside the object.
  • There are subtle differences related to object inheritance with respect to how you write a function name.
  • The value of this is evaluated during runtime.
  • Calling without an object, this === undefined.
  • In non-strict mode, the value of this in such case will be the global object - function() { alert(this); }.
  • In JS, this is free; its value is evaluated at call-time and does not depend on where the method was declared, but rather on what object is "before the dot".
  • Arrow functions have no this.
  • Arrow functions are special. They have no this. When this is accessed inside an arrow function, it is taken from outside (lexical this).

Constructors

  • Constructor functions technically are regular functions.

  • Two conventions:

    • Named with a Capital letter first.
    • Should be executed only with the "new" operator.
  • When a function is executed with "new":

    1. A new empty object is created and assigned to this.
    2. The function body executes. Usually, it modifies this, adds new properties to it.
    3. The value of this is returned.
  • Inside a function, we can check whether it was called with new or without it, using a special new.target property.

    function User() {
      alert(new.target)
    }
    User() // undefined (called without new)
    new User() // function User {...} (called with new)
    
  • You can add a check inside a construction function with if (!new.target) return new User(name);.

  • This approach is sometimes used in libraries to make the syntax more flexible, so that people may call the function with or without new, and it still works.

  • Usually, constructors do not have return statements.

  • If there are return statements:

    • If return is called with an object, then that object is returned instead of this.
    • If return is called with a primitive, it is ignored.
  • In other words, return with an object returns that object. In all other cases, this is ignored.

  • We can omit parentheses after new, if it has no arguments (new User;), but it is not considered good practice.

  • A constructor function is a regular function, but there is a common agreement to name it with a Capital Letter first.

  • A constructor function should only be called with new. Such a call implies creation of this at the start and returning the populated one at the end.

Optional Chaining

  • Optional chaining ?. is a safe way to access nested object properties, even if an intermediate property doesn't exist.
  • If user.address is undefined, an attempt to get user.address.street fails with an Error.
  • Eg: user?.address, user?.address?.street.
  • The ?. syntax makes optional the value before it, but not any further.
  • The variable before ?. must be declared.
  • user?.sayHi(x++) (The x++ part executes before the check of sayHi method).
  • Other variations: ?.(), ?.[].
    • Eg, userAdmin.isAdmin?.() // isAdmin() function is called if it exists.
    • Eg, user?.[key] // access property if it exists.
  • delete user?.name // deletes user.name if it exists.
  • We can use ?. for safe reading and deleting, but not for writing.
  • ?.
    • obj?.prop - returns obj.prop if obj exists, otherwise undefined.
    • obj?.[prop] - returns obj[prop] if obj exists, otherwise undefined.
    • obj.method?.() – calls obj.method() if obj.method exists, otherwise returns undefined.

Symbol Type

  • Symbol is a primitive type for unique identifiers.
  • A "symbol" represents a unique identifier (let id = Symbol("id");).
  • Symbols are guaranteed to be unique.
  • The description is a label; it doesn't affect anything.
  • Symbols are special. Symbols don't auto-convert to strings.
    • alert(id) // error
  • Strings and symbols are fundamentally different and should not accidentally convert one into another.
  • symbol.description is used to show the description.
  • Symbols are skipped by for...in loop.
  • Object.keys(user) also ignores symbols.
  • Object.assign() copies both string and symbol properties. That's by design.
  • There is a global symbol registry. We can create symbols in it and access them later, and it guarantees that related accesses by the same name return exactly the same symbol - Symbol.for(key).
  • Symbols inside the global registry are called global symbols.
  • Symbol.for(key) returns a symbol by name, but there's a reversal call: Symbol.keyFor(sym).
  • If a symbol is not global, Symbol.keyFor won't be able to find it and returns undefined.
  • If we want to add a property into an object that belongs to another script or a library, we can create a symbol and use it as a property key.
  • Technically, symbols are not 100% hidden. There is a built-in method Object.getOwnPropertySymbols(obj) that returns all keys of an object including symbolic ones.

Object to Primitive conversion

  • Objects are auto-converted to primitives, and then the operation is carried out.
  • All objects are true in a boolean context.
  • There are only numeric and string conversions for objects to primitives.
  • ToPrimitive(input, preferredType)
    • Hint: "string"
    • Hint: "number"
    • Hint: "default"
  • The greater and less comparison operators, such as < >, can work with both strings and numbers too. Still, they use the "number" hint and not "default" hint. That's for historical reasons.
  • There is no "boolean" hint.
  • Conversion steps:
    1. Call obj[Symbol.toPrimitive](hint) – the method with the symbolic key Symbol.toPrimitive.
    2. Otherwise, if hint is "string", try obj.toString() and obj.valueOf(), whatever exists.
    3. Otherwise, if hint is "number", or "default", try obj.valueOf() and obj.toString(), whatever exists.
  • obj[Symbol.toPrimitive] = function(hint) { ... // must return primitive value }
  • If toString or valueOf returns an object, then it is ignored, as if there were no methods.
  • By default:
    • The toString method returns a string "[object Object]".
    • The valueOf method returns the object itself.
  • user.valueOf() === user
  • There is no control whether toString returns exactly a string, or whether Symbol.toPrimitive method returns a number for a hint "number".
  • The only mandatory thing: these methods must return a primitive, not an object.
  • For historical reasons:
    • If toString or valueOf returns an object, there is no error, but such values are ignored.
    • Symbol.toPrimitive must return a primitive, otherwise there will be an error.

Data Types (cont.)

  • There are 7 primitive types: string, number, bigint, boolean, symbol, null and undefined.
  • Objects are "heavier" than primitives.
  • Primitive as an Object: A special "object wrapper" that provides the extra functionality is created and then is destroyed.
  • str.toUpperCase():
    1. The string str is a primitive. So, at the moment of accessing its property, a special object is created that knows the value of the string, and has useful methods, like toUpperCase().
    2. That method runs and returns a new string.
    3. The special object is destroyed, leaving the primitive str alone.
  • Constructors String/Number/Boolean are for internal use only.
  • new Number or new Boolean(false) is possible for historic reasons, but not recommended.
  • Things will go crazy at several places if we use new before primitives.
    • let zero = new Number(0); (zero === true in if condition, because objects are always truthy).
  • Null/undefined have no methods.

Numbers

  • Regular numbers are stored in 64-bit IEEE-754 format - also known as "double precision floating point numbers".
  • Regular numbers can't exceed 2**53 to -2**53, so BigInt is used.
  • We can use _ as a number separator like: let billion = 1_000_000_000.
  • _ is only a syntactic sugar.
  • Numbers can be shortened by appending "e" - 1e0 (1 billion), 7.3e9 (7.3 billion).
  • let ms = 1e-6 (six zeros to the left of 1).
  • Hex, binary, and octal numbers are used with their respective prefixes:
    • let a = 0xFF; // 255
    • let b = 0b1111111; // 255
    • let c = 0o377; // 255
  • num.toString(base) - base can be from 2 to 36, default is 10.
    • alert(123456..toString(36)); // 2n9c. If we want to call a method directly on a number, two dots are used.
  • num.toFixed() method returns a string of number. We can prepend + if we want a number: +num.toFixed(2).
  • Numbers stored in memory in their binary form, a sequence of bits. So 0.1 + 0.2 == 0.3 is false.
  • In decimal, division by 10 guarantees a result, but not division by 3. Similarly, in binary, division by 2 works, but not by 10.
  • These errors happen in all languages.
  • The multiply-divide approach reduces the error, but doesn't remove it totally.
  • Out of 64 bits, 52 bits can be used to store digits.
  • In JS, there exist two zeroes, 0 and -0, that's because the sign is represented by a single bit, so it can be set or not set for any number including zero.
  • Infinity (and -Infinity) is a special numeric value that is greater (less) than anything.
  • NaN represents an error. isNaN(value) converts its arguments to a number and then tests it for being NaN.
  • They belong to type Number, but are not "normal" numbers.
    • alert(isNaN(NaN)); // true
    • alert(isNaN("str")); // true
  • We cannot use === NaN comparator. The value NaN is unique in that it does not equal to anything, including itself.
    • alert(NaN === NaN); // false
  • isFinite(value) converts its arguments to a number and returns true if it's a regular number, not NaN/Infinity/-Infinity.
  • isFinite is used to validate whether a string value is a regular number.
  • An empty or space-only string is treated as 0 in all numeric functions, including isFinite().
  • Object.is(value, anotherValue) compares value like ===.
    • It works with NaN. Object.is(NaN, NaN) === true.
    • Values 0 and -0 are different. Object.is(0, -0) === false.
    • Object.is(a, b) is same as a === b.
  • Numeric conversion using unary + or Number() is strict. If value is not exactly a number, it fails.
    • alert(+"100px"); // NaN
  • In the above situation, spaces at beginning or ending are ignored.
  • In the above situation, parseInt() and parseFloat() functions should be used.
  • They "read" a number from a string, until they can't. In case of an error, the gathered number is returned.
  • There are situations when parseInt/parseFloat will return NaN. It happens when no digits could be read.
  • The second argument of parseInt(str, radix) can be any base between 2 and 36.
  • Result is always in decimal.
  • Math.random() returns a number from 0 to 1, excluding 1.
  • Math.max(a, b, c, ...) / Math.min(a, b, c, ...) returns the greatest or smallest number from a list of arguments.

Strings

  • Single and double quotes are essentially the same.
  • Backticks, however, allow us to embed any expression into a string, by wrapping it in ${...}.
  • Another advantage of backticks is that they allow a string to span multiple lines.
  • Backticks also allow us to specify a "template function" before the first backtick. The syntax is func`string`. This is called"tagged templates"`.
  • The length property has the string length.
  • The \n is a single "special" character, so "My\n".length === 3.
  • str.length is a numeric property, not a function.
  • A character from the string can be obtained by theString.charAt(0) (gets first char) or theString[theString.length - 1] (gets last char).
  • Square brackets are the modern way of getting a character; charAt() exists mostly for historic reasons.
  • [] returns undefined and charAt returns an empty string, if no character is found.
  • We can also use for...of loop on a string to access characters.
  • Strings can't be changed in JS. So str[0] = 'h' will throw an error.
  • toUpperCase() and toLowerCase() functions can be used on strings, as well as on characters like theString[0].toUpperCase().
  • str.indexOf(substr, pos) looks for substr in str, starting from the given position pos, and returns the position where the match was found, or -1 if nothing can be found.
  • The optional second parameter allows us to start searching from a given position.
  • str.lastIndexOf(substr, pos) searches from the end to the beginning.
  • The bitwise NOT trick (~1) is sometimes used at some places for the replacement of str.lastIndexOf("text") != -1.
  • In practice, that means a simple thing: for 32-bit integers, ~n equals -(n+1).
  • ~n is zero only if n === -1.
  • So, the test if (~str.indexOf("...")) is truthy only if the result of indexOf is not -1.
  • Above is not recommended to use in a non-obvious way.
  • Just remember if (~str.indexOf(...)) reads as "if found".
  • Big numbers are truncated to 32 bits by ~ operator, so use it on a string which is smaller.
  • str.includes(), str.startsWith(), str.endsWith() returns true or false.

Getting a substring

  1. str.slice(start [, end])
    • Returns the part of string from start, but not including end.
    • If there is no second argument, then slice goes till the end of the string.
    • Negative values of start, end are possible. It means counted from the end.
  2. str.substring(start [, end])
    • Returns the part of string between start and end.
    • Almost similar to slice, but allows start to be greater than end.
    • Negative arguments are not supported; they are considered to be as 0.
  3. str.substr(start [, length])
    • Returns the part of the string from start, with the given length.
    • The first argument can be negative, to count from the end.
methodSelects...negatives
slice(start, end)From start to end (excluding end)Allows negatives
substring(start, end)Between start and endNegative values means 0
substr(start, length)From start get length charactersAllows negative start
  • It's enough to remember solely slice of these three methods.

Comparing Strings

  • A lowercase letter is always greater than uppercase.
  • Letters with diacritical marks are "out of order"; may give strange results.
  • All strings are encoded using UTF-16.
  • str.codePointAt(pos) returns the code for the character at position pos.
  • String.fromCodePoint(code) - creates a character by its numeric code.
  • We can add Unicode characters by their code using \u followed by the hex code.
  • The characters are compared by their numeric codes.
  • The code for a (97) is greater than the code for Z (90).
  • The call str.localeCompare(str2) returns an integer indicating whether str is less, or equal, or greater than str2 according to the language rules:
    1. Returns a negative number if str is less than str2.
    2. Returns a positive number if str is greater than str2.
    3. Returns 0 if they are equivalent.
  • Surrogate Pair
    • All frequently used characters have 2-byte codes.
    • But 2 bytes only allow 65,536 combinations, and that's not enough for every possible symbol.
    • We actually have a single symbol in each of the string (like 👶🏽👩‍🏽‍💻), but the length shows a length of 2.
    • String.fromCodePoint and str.codePointAt methods deal with surrogate pairs correctly.
    • Above methods are actually the same as fromCodePoint/codePointAt, but don't work with surrogate pairs.
    • To support arbitrary compositions, UTF-16 allows us to use several Unicode characters: the base character followed by one or many "mark" characters that "decorate" it.
      • alert('S\u0307\u0323'); // S with dot above symbol
    • str.normalize() actually brings together a sequence of three characters to one.
    • str.trim() trims the spaces around.
    • str.repeat(n) repeats the string n times.

Arrays

  • Arrays can store elements of any type.
  • The "trailing comma" style makes it easier to insert/remove items, because all lines become alike.
  • Arrays as queue
    • push adds an element to the end.
    • shift gets an element from the beginning, advancing the queue, so that the second element becomes the first.
    • shift <== array <== push.
  • There is another use case for Arrays – the data structure named a stack.
    • push adds an element to the end.
    • pop takes an element from the end.
  • For stacks, the latest pushed item is received first; that's also called as LIFO (last in, first out) principle. For Queues, we have FIFO (first in, first out).
  • In computer science, the data structure that allows this is called a deque (double-ended queue).
  • fruits.push(...) is equal to fruits[fruits.length] = ....
  • Push and shift can add multiple items at once.
  • An Array is a special kind of object, and thus behaves like an object.
  • Think of arrays as special structures to work with ordered data. They provide special methods for that.
  • Methods push/pop run fast, while shift/unshift are slow.
  • The shift operation does 3 things:
    1. Remove the element with index 0.
    2. Move all elements to the left, renumber them from index 1 to 0, from 2 to 1, and so on.
    3. Update the length property.
  • The pop method does not need to move anything, because other elements keep their indexes. That's why it's blazingly fast.
  • One can use for loop, but there is also a special for...of loop.
  • for...of doesn't give access to the number of the current element, just its value, but in most cases that's enough and that's shorter.
  • The loop for...in iterates over all properties, not only the numeric ones.
  • The for...in loop is optimized for generic objects, not arrays, and thus 10-100 times slower.
  • Generally, we shouldn't use for...in for arrays.
  • In arrays, length property is writable. The simplest way to clear the array is arr.length = 0.
  • If new Array is called with a single argument which is a number, then it creates an array without items, but with the given length.
  • toString() method returns a comma-separated list of elements (without space).
  • Don't compare arrays with ==.
    • Two objects are equal (==) only if they are references to the same object.
    • If one of the arguments of == is an object, and the other one is primitive, then the object gets converted into primitive.
    • With an exception of null and undefined that equal == each other and nothing else.
    • They are never the same, unless we compare two variables that reference exactly the same array.
  • Some comparisons:
    • [] == []; // false
    • [0] == [0]; // false
    • 0 == []; // true
    • '0' == []; // true
  • To compare arrays, don't use == operator (as well as <, >, and others).
  • arr.push(...items) - adds items to the end.
  • arr.pop() - extracts an item from the end.
  • arr.shift() - extracts an item from the beginning.
  • arr.unshift(...items) - adds items to the beginning.
  • If any of the middle elements was removed with delete arr[3], then arr.length still remains the same.
  • The arr.splice method is a Swiss Army knife for arrays. It can do everything: insert, remove, and replace elements.
    • arr.splice(start, deleteCount, elem1, ..., elemN)
    • It modifies arr starting from the index start, removes deleteCount elements, and then inserts elem1, elem2, and so on at their place. Returns the array of removed elements.
  • The splice method is also able to insert the elements without any removals. Just set deleteCount to 0.
  • It's similar to str.slice, but instead of substrings, it makes a subarray.
  • If we call arr.slice() method without arguments, then it creates a copy of arr.
  • arr.concat creates a new array that includes values from other arrays and additional items.
  • Normally, it only copies elements from arrays. Other objects, even if they look like arrays, are added as a whole.
  • If an array-like object has a special Symbol.isConcatSpreadable property, then it's treated as an array by concat.
  • arr.forEach(item, index, array) { }
  • arr.indexOf(item, from) - looks for item starting from index from, and returns the index where it was found, otherwise -1.
  • arr.lastIndexOf(item, from) - same, but looks for from right to left.
  • arr.includes(item, from) - looks for item starting from index from, returns true if found.
  • Above three methods use === comparison.
  • A very minor difference of includes is that it correctly handles NaN, unlike indexOf/lastIndexOf.
  • arr.find(item, index, array) { }
    • The find method looks for a single (first) element.
  • The filter function returns an array of all matching elements.
  • arr.map(item, index, array) { }
  • arr.sort - sorts the array in place, changing its element order.
    • It also returns the sorted array, but the returned value is usually ignored, as arr itself is modified.
    • The items are sorted as strings by default (lexicographic ordering - "2" > "15").
  • arr.sort(fn) method implements a generic sorting algorithm.
    • arr.sort((a, b) => a - b). The comparison function is only required to return a positive number to say "greater" and a negative number to say "less".
  • Use a.localeCompare(b) for strings.
  • arr.reverse() reverses the order of the elements of arr. It also returns the arr after the reversal.
  • The arr.split() method has an optional second numeric argument, to limit the length of the returning array.
  • arr.reduce(accumulator, item, index, array) { }
  • reduce uses only 2 arguments; that's typically enough.
  • If there is no initial value, then reduce takes the first element of the array as the initial value and starts the iteration from the second element.
  • But such use requires extreme care. If the array is empty, then reduce call without initial values gives an error.
  • The method arr.reduceRight does the same thing, but goes from right to left.
  • Array.isArray(arr) - checks if it's an array.
  • Almost all array methods that call functions – like find, filter, map – with a notable exception of sort, accept an optional additional parameter thisArg.
  • Methods sort, reverse, splice modify the array itself.
  • arr.fill(value, start, end) – fills array with repeating value from index start to end.
  • arr.copyWithin(target, start, end) - copies its elements from position start till position end into itself, at position target (overwrites existing).
  • arr.flat(depth) / arr.flatMap(fn) - creates a new flat array from a multidimensional array.

Iterables

  • Iterable objects are a generalization of arrays. They make any object usable in a for...of loop.

  • To make an object iterable, we need to implement Symbol.iterator method.

  • When for...of starts, it calls that method once. The method must return an iterator – an object with the name next.

  • Onward, for...of works only with that returned object.

  • While for...of wants the next value, it calls next() on that object.

  • The result of next() must have the form {done: Boolean, value: any}, where done: true means iteration has finished, otherwise value is the next value.

  • There are no limitations on next; it can return more and more values, that's normal.

  • for...of loop can be stopped using break.

  • String is iterable with for...of, and it works correctly with surrogate pairs.

  • We can call iterator explicitly:

    let iterator = str[Symbol.iterator]()
    
    while (true) {
      let result = iterator.next()
      if (result.done) break
      alert(result.value)
    }
    
  • Iterables are objects that implement the Symbol.iterator method.

  • Array-likes are objects that have indexes and length. So they look like arrays.

  • Strings are both iterables and Array-likes.

    let arrayLike = {
      0: 'hello',
      1: 'World',
      length: 2,
    }
    // Error, no Symbol.iterator
    
  • Both iterables and array-likes are usually not arrays. They don't have push, pop, etc.

  • There's an Array.from method that takes an iterable or array-like value and makes a "real" array.

  • Array.from takes an object, examines it for being an iterable or array-like, then makes a new array and copies all items to it.

  • Array.from(obj[, mapFn, thisArg])

  • Array.from can be used to convert a string into an array.

  • Unlike str.split, it relies on the iterable nature of the string and so, just like for...of, correctly works with surrogate pairs.

    function surrogateAwareSplit(str, start, end) {
      return Array.from(str).slice(start, end).join('')
    }
    

Map

  • Map is a collection of keyed data items, just like an Object. But the main difference is that Map allows keys of any type.
  • new Map() - creates a map.
  • map.set(key, value) - stores the value by key.
  • map.get(key) - returns the value by the key. undefined if key doesn't exist in map.
  • map.has(key) – returns true if the key exists, false otherwise.
  • map.delete(key) removes the value by the key.
  • map.clear() removes everything from the map.
  • map.size - returns the current element count.
  • map[key] isn't the right way to use a Map.
  • Using objects as keys is one of the most stable and important Map features. The same does not count for Object. String as a key in Object is fine, but we can't use another Object as a key in Object.
  • To test keys for equivalence, Map uses the algorithm SameValueZero.
  • It is roughly same as strict equality ===, but the difference is that NaN is considered equal to NaN. So NaN can be used as the key.
  • Chaining - Every map.set call returns the map itself, so we can "chain" the calls.
  • Iteration on Map
    • map.keys() - returns an iterable for keys.
    • map.values() - returns an iterable for values.
    • map.entries() - returns an iterable for entries [key, value].
  • Map has built-in forEach method like Arrays.
  • A Map from Object - let map = new Map(Object.entries(obj)).
  • An Object from Map - let object = Object.fromEntries(map.entries()).

Set

  • A Set is a special type collection - "set of values" without keys, where each value may occur only once.
  • new Set(iterable) - creates the set, and if an iterable object is provided (usually an array), copies values from it into the set.
  • set.add(value) - adds the value, returns the set itself.
  • set.delete(values) - removes the value, returns true if the value existed at the moment of the call, otherwise false.
  • set.has(value) – returns true if the value exists in the set, otherwise false.
  • set.clear() removes everything from the set.
  • set.size() - is the elements count.
  • Set is much better optimized internally for uniqueness checks.
  • We can loop over a set either with for...of or using forEach.
  • Supports
    • set.keys()
    • set.values()
    • set.entries() - returns [value, value].

WeakMap

  • Usually, properties of an object or elements of an array or another data structure are considered reachable and kept in memory while that data structure is in memory.

  • If we put an object into an array, then while the array is alive, the object will be alive as well, even if there is no other reference to it.

  • Similarly, if we use an object as the key in a regular Map, then while the Map exists, that object exists as well. It occupies memory and may not be garbage collected.

  • WeakMap is fundamentally different in this aspect. It doesn't prevent garbage collection of key objects.

  • The first difference between Map and WeakMap is that keys must be objects, not primitive values.

  • WeakMap does not support iteration and methods keys(), values(), entries(), so there is no way to get keys, values or entries.

  • WeakMap has only four methods: get, set, has, and delete.

  • The main area of application for WeakMap is additional data storage.

  • If we would like to store some data associated with an object, that should only exist while the object is alive - then WeakMap is exactly what we needed.

  • Another common example is caching. We can store "cache" results from a function.

    let cache = new Map(); // Can be WeakMap for garbage collection
    
    function process(obj) {
      if (cache.has(obj)) {
        return cache.get(obj);
      }
    
      let result = /* ... complicated calculation ... */;
      cache.set(obj, result);
      return result;
    }
    
    let result = process(obj);
    
  • Multiple calls of the process(obj) with the same object, it only calculates the result the first time, and then just takes it from cache.

  • If we replace Map with WeakMap, then the problem is disappeared.

  • Cached results will be removed from the memory automatically after the object gets garbage collected.

WeakSet

  • It is analogous to the Set, but we may only add objects to WeakSet (not primitives).
  • The most notable limitation of WeakMap and WeakSet is the absence of iterations, and the inability to get all current contents.
  • Their main job is to be "additional" storage of data for objects.

Object.keys, values, entries

  • If we create a data structure of our own, then we should implement keys, values, entries.
  • They are supported for:
    • Map
    • Set
    • Array
  • The first difference is that we have to call Object.keys(obj) and not obj.keys(), while in Maps we can call map.keys().
  • The second difference is that Object.* methods return "real" array objects, not just an iterable. That's mainly for historic reasons.
  • Object.keys, values, entries ignores symbolic properties.
  • If we want symbolic keys as well, we need to use Object.getOwnPropertySymbols() returning only symbolic keys.
  • There is also Reflect.ownKeys(obj) that returns all keys.
  • Object lacks many methods that exist for arrays – map, filter.
  • We can use Object.entries followed by Object.fromEntries.

Destructuring assignments

  • Destructuring assignment is a special syntax that allows us to "unpack" an object/array as a whole.

  • Destructuring does not mean destructive - original arrays/objects are not modified.

  • let [firstName, lastName] = arr;

  • Unwanted elements of the array can also be thrown away via extra comma: let [firstName,, title] = arr;.

  • We can use it with any iterables, not only arrays.

    • let [a, b, c] = "abc";
    • let [one, two, three] = new Set([1, 2, 3]);
    • It works because internally it iterates over each element with for...of.
  • We can use any "assignable" at the left side.

    let user = {}
    ;[user.name, user.surname] = 'John Smith'.split(' ')
    
  • for (let [key, value] of Object.entries(obj)) // for objects

  • for (let [key, value] of map) // for maps

  • Swap Variable Trick - [guest, admin] = [admin, guest] - More than 2 variables can be swapped.

  • The rest ... pattern - let [name1, name2, ...rest] = ["Julius", "Caesar", "Consul", "of the roman republic"];

  • Just make sure it has three dots before it and goes last in the destructuring assignment.

  • Absent values are considered undefined.

    • let [firstName, lastName] = []; // undefined, undefined
  • Default values can be a more complex expression or even function calls.

    • let [name = "guest", surname = "Anonymous"] = ["Julius"];
  • In Object destructuring, order does not matter on the left-hand side (let { z, x, y } = {}).

  • We can set variable names using a colon:

    • let { width: w, height: h, title } = {title: "Menu", width: 100, height: 200};
  • For potentially missing properties, we can set default values using =:

    • let { width = 100, height = 200, title } = options;
  • We can also combine both colon and equality:

    • let { width: w = 100, height: h = 100, title } = options;
  • We can use the rest (...) pattern just like arrays.

  • This won't work:

    let title, width, height;
    { title, width, height } = options;
    
    • The problem is that JS treats {...} in the main code flow (not inside another expression) as a code block. Such close blocks can be used to group statements.
  • To Show JS that it's not a code block, we can wrap that expression in parentheses (...).

  • Smart function parameters

    • function({ incomingProperty: varName = defaultValue }) { /* ... */ }
      function showMenu({ title = "Menu", width = 100, height = 200 } = {}) { /* ... */ }
      
  • The full syntax is let { prop: varName = default, ...rest } = options;.

Date and Time

  • new Date() - creates a new Date object.
  • new Date(milliseconds) - creates a new Date object with the time equal to the number of milliseconds passed after Jan 1st, 1970 UTC+0.
  • Integer number passed since the beginning of 1970 is called timestamp.
  • date.getTime() function returns the timestamp.
  • new Date(datestring) - creates a new Date() object. If time is not passed, it is midnight GMT.
  • Dates before 1/1/1970 have negative timestamps.
  • new Date(year, month, date, hours, minutes, seconds, ms)
  • year is four digits, month starts from 0 (Jan) to 11 (Dec), day is absent it is set to 1, rest 0 by default.
  • Access Date Component
    • getFullYear() // not getYear() which is deprecated
    • getMonth()
    • getDate()
    • getHours()
    • getMinutes()
    • getSeconds()
    • getMilliseconds()
    • getDay() - returns 0 (Sunday) to 6 (Saturday) – first day is always Sunday.
    • All above methods have UTC version getUTCxxx().
    • getTime() - returns timestamp.
    • getTimezoneOffset() - returns difference between UTC and the local time zone.
  • Setting date component
    • setFullYear(year, [month], [date])
    • setMonth(month, [date])
    • setDate(date)
    • setHours(hour, [min], [sec], [ms])
    • setMinutes(min, [sec], [ms])
    • setSeconds(sec, [ms])
    • setMilliseconds(ms)
    • setTime(milliseconds)
  • Autocorrection - We can set out-of-range values, and it will auto-adjust itself.
    • date.setDate(date.getDate() + 2); // two days later
    • date.setSeconds(date.getSeconds() + 70); // 70 seconds later
    • date.setDate(1); // set date to 1 of this month
    • date.setDate(0); // set date to the last date of previous month
  • When a date object is converted to a number, it becomes the timestamp, same as date.getTime() (alert(+date)).
  • Dates can be subtracted; the result is their difference in milliseconds.
  • Date.now() returns the current timestamp, similar to new Date().getTime(), but it doesn't create an intermediate Date object.
  • Date.now() is faster and doesn't put pressure on garbage collection.
  • Using getTime() is so much faster! That's because there's no type conversion. It is much easier for engines to optimize.
  • The method Date.parse(str) can read a date from a string.
  • The string format should be: "YYYY-MM-DDTHH:mm:ss.sssZ" where:
    • YYYY-MM-DD is the date.
    • The character "T" is used as the delimiter.
    • HH:mm:ss.sss - the time: hours, minutes, seconds and milliseconds.
    • The optional 'Z' part denotes the time zone in the format +-hh:ms. A single letter Z (Zulu) means UTC+0.
  • Shorter variants are also possible, like YYYY-MM-DD, YYYY-MM, or even YYYY.
  • Date.parse(str) parses the string in the given format and returns the timestamp (number of milliseconds from 1 Jan, 1970 UTC+0).
  • We can create a date from a timestamp: let date = new Date(timestamp).
  • We can't create "only date" or "only time". Date objects always carry both.
  • Months are counted from zero (Jan = 0).
  • Days of the week are also counted from zero (Sunday = 0).
  • Date auto-corrects itself in case of out-of-range components.
  • Dates become timestamps when converted to numbers, hence can be used in arithmetic.
  • Use Date.now() to get timestamp fast.
  • JavaScript does not have a way to measure performance, but environments have (performance.now() function).
  • Node.js has a microtime module and other ways to get more precision.

JSON methods, toJSON

  • JSON (JavaScript Object Notation) is described in RFC 4627.

  • Initially made for JS, but handled by other language libraries as well.

  • JSON.stringify() converts an object into JSON.

  • JSON.parse converts JSON back into an object.

  • The result string of JSON.stringify() is called JSON-encoded, serialized, stringified or marshalled.

  • Strings use double quotes only, no single quote, or backticks.

  • Supported Data Types

    • Objects {...}
    • Arrays [...]
    • Primitives:
      • Strings
      • Numbers
      • Boolean values true/false
      • Null
  • JSON.stringify(variable) is the same variable type.

  • JSON is a data-only, language-independent specification, so it skips following:

    • Function properties (methods)
    • Symbolic keys and values
    • Properties that store undefined.
    let user = {
      sayHi() {
        alert('Hello')
      },
      [Symbol('id')]: 123,
      something: undefined,
    }
    alert(JSON.stringify(user)) // {}
    
  • Nested objects are supported and converted automatically.

  • The important limitation: there must be no circular reference.

  • Full Syntax

    • JSON.stringify(value[, replacer, space])
    • replacer - Array of properties to encode or a mapping function function(key, value).
    • space - amount of space used for formatting.
  • JSON.stringify(meetup, ['title', 'participants'])

  • The function replacer will be called on every (key, value) pair and should return the "replaced" values, otherwise undefined if to skip.

  • return (key === 'keyToSkip') ? undefined : value;

  • The value of this inside replacer is the object that contains the current property.

  • The first (key, value) pair has an empty key, and the value is the target object as a whole.

  • The space can be a number or a string.

  • JSON.parse

    • JSON.parse(str[, reviver])
    • reviver - optional function(key, value) that will be called for each (key, value) pair and can transform the value.
    • Return the value or changed value.
    • It works on nested objects as well.
  • Adding comments to JSON makes it invalid.

Functions, Recursions and Stack

  • Recursion is a programming pattern useful when a task can be split into.
  • Two ways of thinking:
    • Iterative Thinking
    • Recursive thinking
  • Base of recursion - it immediately produces an obvious result.
  • Recursive step - simple call to the same task.
  • Recursive solutions are usually shorter than an iterative one.
  • Maximum number of nested calls is called recursion depth.
  • Max recursion depth is limited by the JavaScript engine. We can rely on it being 10K, sometimes 100K (depends on the engine).
  • The information about the process of execution of the running function is stored in its execution context.
  • The execution context is an internal data structure that contains details about the execution of a function:
    • Where the control flow is now.
    • The current variables.
    • The value of this.
    • And a few other internal details.
  • When a function makes a nested call:
    • The current function is paused.
    • The execution context associated with it is remembered in a special data structure called execution context stack.
    • The nested call executes.
    • After it ends, the old execution context is retrieved from the stack, and the outer function is resumed from where it stopped.
  • Contexts take memory.
  • Loop-based algorithms are more memory-saving.
  • Any recursion can be rewritten as a loop. The loop variant usually can be made more effective.
  • Linked List
    • A LinkedList is a recursive structure - a better alternative for arrays in some cases.
    • "Delete element" and "insert element" are two problems with arrays; they are expensive operations.
    • Same with array.unshift() and array.shift() functions.
    • If we need really fast insertion and deletion, we need to use a linked list.
    • A Linked List is an object with two properties: value and next.
  • Lists are not always better than arrays.
  • The main drawback is that we can't easily access an element by its number.
  • It is best used where adding/deleting from both ends is required.
  • We can add prev to move back and forth.

Rest parameters and spread syntax

  • There will be no errors due to excessive arguments passed to a function call.
  • function sumAll(...args) - args is the name for the array.
  • The rest parameter must be at the end; causes an error otherwise.
  • There is also a special array-like object named "arguments" that contains all arguments by their index.
  • In old languages, when rest params were not available, arguments was used.
  • arguments is not an array, but an array-like and iterable object. Does not support array functions.
  • Arrow functions do not have "arguments" and "this".
  • Spread Syntax looks like a rest parameter but does quite the opposite.
    • Math.max(...arr, ...another)
  • Spread syntax can be used to merge arrays.
  • The spread syntax internally uses iterables to gather elements, the same way for...of does.
  • The list of characters is passed to an array initializer.
  • The result of [...str] is same as Array.from(str).
    • But the only difference is:
    • Array.from operates on both array-like and iterables.
    • The spread syntax works only on iterables.

Copy of an object/array

  • Object.assign()
  • let arrCopy = [...arr]
  • alert(JSON.stringify(arr) === JSON.stringify(arrCopy)) // true
  • alert(arr === arrCopy) // false
  • Similar thing is possible with an object.
  • Spread syntax is preferred compared to Object.assign().
    • let copyOb = Object.assign({}, obj)
    • let copyArr = Object.assign([], arr)
  • When we see ..., it's either a rest parameter or spread parameter.
    • Rest parameters - is at the end, and gathers the rest of the list of arguments into an array.
    • Spread syntax - is at the beginning and expands an array into the list.
  • Rest parameters are used to create functions that accept any number of arguments.
  • The spread syntax is used to pass an array to functions that normally require a list of many objects.

Variable scope, closure

  • If a variable declared inside a code block {...}, it's only visible inside that block.
  • For if, for, while, and so on, variables declared in {...} are also only visible inside.
  • A function is called "nested" if it is created inside another function.
  • What's more interesting, a nested function can be returned, either as a property of a new object or as a result by itself. It can then be used somewhere else. No matter where. It still has access to the same outer variables.
  • Slightly modified variants of that code have practical uses, for instance, as a random number generator.

Lexical Environment

  • Step 1: Variables
    • In JS, every running function, code block {...}, and the script as a whole have an internal (hidden) associated object known as the Lexical Environment.
    • Lexical Environment has two parts:
      • Environment Record - An object that stores all local variables as its properties.
      • A reference to the outer lexical environment. As one associated with the outer code.
    • A "variable" is just a property of the special internal object, Environment Record. To get or change a variable means to get or change a property of that object.
    • Lexical Environment is a specification object - it exists only "theoretically".
  • Step 2: Function Declaration
    • A function is also a value, like a variable.
    • The difference is that a Function Declaration is instantly fully initialized.
    • That's why we can use a function even before its declaration.
  • Step 3: Inner and outer Lexical Environment
    • When the code wants to access a variable - the inner lexical Environment is searched first, then the outer one, then the more outer one, and so on until the global one.
    • If a variable is not found anywhere, that's an error in strict mode (without "use strict", assignment to a non-existing variable creates a new global variable, for compatibility with the old code).
  • Step 4: Returning a function
    • All functions remember the Lexical Environment in which they were made.
    • Technically, there's no magic here. All functions have the hidden property named [[Environment]], that keeps the reference to the Lexical Environment where the function was created.
    • So counter.[[Environment]] has the reference to {count: 0} Lexical Environment. That's how the function remembers where it was created, no matter where it's called. The [[Environment]] reference is set once and forever at function creation time.
    • A new Lexical Environment is created for the call, and its outer Lexical Environment reference is taken from counter.[[Environment]].
    • A variable is updated in the Lexical Environment where it lives.
    • A closure is a function that remembers its outer variables and can access them.
    • They automatically remember where they were created using a hidden [[Environment]] property, and then their code can access outer variables.

Garbage Collection (cont.)

  • A Lexical Environment is removed from memory, as any JavaScript object; it's only kept in memory while it's reachable.

  • However, if there's a nested function that is still reachable after the end of a function, then it has [[Environment]] property that references the lexical environment.

  • A Lexical Environment object dies when it becomes unreachable.

    let g = f() // Lexical environment exists
    g = null // Now the Lexical environment can be garbage collected if no other references.
    

The Old "var"

  • Internally, var is a very different beast that originates from very old times. It's generally not used in modern scripts, but still lurks in the old ones.

  • It's important to understand the difference when migrating old scripts from var to let, to avoid odd errors.

  • "var" has no block scope. Variables declared with var are tighter function-scoped or global-scoped.

  • "var" ignores code blocks.

  • If a code block is inside a function, then var becomes a function-level variable.

  • Long ago, there were no Lexical Environments in JS. "var" is a remnant of it.

  • "var" tolerates re-declarations any number of times. Already ignored variables are ignored.

  • "var" variables can be declared before their use.

  • People call such behavior "hoisting" (raising), because all var declarations are "hoisted" (raised) to the top of the function.

  • Declarations are hoisted, but assignments are not.

  • IIFE (Immediately Invoked Function Expressions)

    • In old scripts, there was only var - it has no block-level visibility - so programmers invented IIFE.
    • That's not something we should use nowadays, but you can find them in old scripts.
    ;(function () {
      // ...
    })()
    
    • Parentheses around the function are a trick to show JavaScript that the function is created in the context of another expression, and hence it's a Function Expression: It needs no name and can be called immediately.

    • Ways to create IIFE:

      ;(function () {
        /* ... */
      })()
      ;(function () {
        /* ... */
      })() // Parenthesis around the whole thing
      !(function () {
        /* ... */
      })() // Bitwise NOT operator starts the expression
      ;+(function () {
        /* ... */
      })() // Unary plus operator starts the expression
      
    • Nowadays there is no need to write such codes.

  • var variables have no block scope; their visibility is scoped to the current function, or global if declared outside a function.

  • var declarations are processed at function start (script start for globals).

  • These differences make var worse than let most of the time.

Global Object

  • In a browser, it is named window; for Node.js, it is global.

  • Recently globalThis was added to the language, supported by most environments and browsers.

  • Using global variables is generally discouraged.

  • Using for Polyfills:

    if (!window.Promise) {
      window.Promise = {
        /* ... custom Promise implementation ... */
      }
    }
    

Function object, NFE

  • name property

    • A function in JavaScript is a value, but what type of value?
    • In JavaScript, functions are objects.
    • All functions have a name property.
    • let sayHi = function() { /* ... */ }; alert(sayHi.name); // sayHi
    • When there is no name, it's an empty string.
    • Most of the time, the name has a value.
  • length property

    • Returns the number of function parameters.
    • Rest parameters are not counted.
  • Custom properties

    function sayHi() {
      sayHi.counter++
    }
    sayHi.counter = 0
    sayHi()
    sayHi()
    
    • A property is not a variable.
    • A property counter and a variable let counter are two unrelated things.
    • Variables are not function properties and vice versa. There are just parallel worlds.
    • Function properties can replace closures sometimes.
  • Named Function Expression (NFE) is a term for a Function Expression that has a name.

    • let sayHi = function func(who) { /* ... */ };
    • It allows the function to reference itself internally.
    • It is not visible outside of the function.
    • "func" is function-local; it is not taken outside.
    • "func" is an internal function name: how the function can call itself internally.
    • The internal name feature described here is only available for Function Expressions, not for Function Declarations, for which no syntax is present for adding "internal" name.

The "new Function" syntax

  • let func = new Function([arg1, arg2, ...argN], functionBody)

  • Examples

    let sum = new Function('a', 'b', 'return a+b')
    let sum2 = new Function(`a,b`, `return a+b`)
    let sum3 = new Function(`a b`, `return a+b`)
    
  • The major difference from other ways is that the function is created literally from a string. That is passed at run time.

  • new Function allows us to turn any string into a function.

Closure (cont.)

  • Usually, a function remembers where it was born in the special property [[Environment]].
  • But when a function is created with new Function, its [[Environment]] is set to reference not the current Lexical Environment, but the global one.
  • If new Function had access to outer variables, it would have problems with minifiers.
  • Passing parameters explicitly is a much better method architecturally and causes no problems with minifiers.

Scheduling: setTimeout and setInterval

  • setTimeout allows us to run a function once after an interval of time.

  • setInterval allows us to run a function repeatedly, starting after the interval of time, then repeating continuously at that interval.

  • setTimeout

    • let timerId = setTimeout(func|code, [delay], [arg1, arg2, ...argN])
    • For historical reasons, a string of code can be passed, but that's not recommended.
    • Arguments are not supported by IE9-.
    • delay is in milliseconds, default value is 0.
    • If the first argument is a string, then JS creates a function from it, but it's not recommended to use strings.
    • Pass a function, but don't run it (i.e., don't use func()).
    • setTimeout expects a reference to a function.
    • A call to setTimeout returns a "timer identifier" that can be used to cancel the execution.
    • clearTimeout(timerId)
    • In a browser, the timer identifier is a number; in other environments, this can be something else (e.g., Timer object in Node.js).
  • setInterval

    • The setInterval method has the same syntax as setTimeout.
    • Unlike setTimeout, it runs the function not only once, but regularly after the given interval of time.
    • To stop further calls, we should call clearInterval(timerId).
    • Timer goes on while alert is shown.
  • Nested setTimeout

    • Two ways to run something regularly: setInterval or Nested setTimeout.

    • let timerId = setTimeout(function tick() {
        alert('tick')
        timerId = setTimeout(tick, 2000) // Schedule next tick
      }, 2000)
      
    • The nested setTimeout is a more flexible method than setInterval. This way, the next call may be scheduled differently, depending on the result of the current one.

    • If the functions that we're scheduling are CPU-hungry, then we can measure the time taken by the execution and plan the next call sooner or later.

    • Nested setTimeout allows to set the delay between the executions more precisely than setInterval.

  • The real delay between function calls for setInterval is less than in the code.

  • It is possible that a function's execution turns out to be longer than we expected and takes more than the given interval.

  • In the edge case, if the function always executes longer than delay ms, then the calls will happen without a pause at all.

  • The nested setTimeout guarantees a fixed delay.

  • For setInterval, the function stays in memory until clearInterval is called.

  • A function reference is the outer lexical environment, so, while it lives, outer variables live too. They may take much more memory than the function itself. So when we don't need the scheduled function anymore, it's better to cancel it, even if it's very small.

  • Zero Delay setTimeout

    • setTimeout(func, 0) or just setTimeout(func).
    • This schedules the execution of func as soon as possible.
    • The first line "puts the call into calendar after 0 ms". But the schedule will only "check the calendar" after the current script is complete.
    • Zero delay is in fact not zero (in a browser).
    • HTML5 standard says "after five nested timers the interval is forced to be at least 4 ms".
    • The similar thing happens if we use setInterval instead of setTimeout. The setInterval(f) runs f a few times with Zero delay, and afterwards with 4+ Millisecond delay.
    • All scheduling methods do not guarantee the exact delay.

Decorators and forwarding, call/apply

  • function cachingDecorator(func) {
      let cache = new Map() // A Map to store results
    
      return function (x) {
        // The wrapper function
        if (cache.has(x)) {
          return cache.get(x) // Return cached result if available
        }
    
        let result = func(x) // Call the original function
        cache.set(x, result) // Cache the result
        return result
      }
    }
    
    // Example usage:
    // let slow = cachingDecorator(originalSlowFunction);
    
  • Benefits:

    • The cachingDecorator is reusable. We can apply it to another function.
    • The caching logic is separate. It did not increase the complexity of slow itself.
    • We can combine multiple decorators if needed.
  • The caching decorator mentioned above is not suited to work with object methods.

  • func.call(context, arg1, arg2, ...argN)

    let result = func.call(this, x) // Calls func with 'this' context and argument 'x'
    
    let worker = {
      slow: function (x) {
        /* ... original slow logic ... */
      },
    }
    worker.slow = cachingDecorator(worker.slow) // Decorate the method
    
  • How this is passing:

    • After the decoration, worker.slow is now the wrapper function function(x) { ... }.
    • So when worker.slow(2) is executed, the wrapper gets 2 as an argument and this=worker (its object before the dot).
    • Inside the wrapper, assuming the result is not yet cached, func.call(this, x) passes the current this (worker) and the current argument (x) to the original method.

Going Multi Arguments

  • The native Map takes a single value only as the key.

  • Three solutions for multi-argument caching:

    1. Implement a new map-like data structure.
    2. Use nested maps.
    3. Join two values into one.
  • For practical applications, the third one is a good solution.

    let key = hash(arguments)
    if (cache.has(key)) return cache.get(key)
    
  • Hash functions:

    function hash(args) {
      return args[0] + ',' + args[1]
    } // Simple concatenation
    function hash(args) {
      return args.join()
    } // Using Array.prototype.join
    
    • Unfortunately, arguments.join() won't work directly because arguments object is both iterable and array-like but not a real array.
    function hash(args) {
      return [].join.call(arguments)
    } // Method borrowing
    
  • This trick is called method borrowing.

Two methods (call and apply)

  • func.call(context, ...args)
  • func.apply(context, args)
  • func.apply will probably be faster, because most JS engines internally optimize them better.

Function bindings

  • When passing object methods as callbacks, there is a known problem: "losing this".

    setTimeout(user.sayHi, 1000) // user.sayHi will lose its 'this' context
    // This is equivalent to:
    // let f = user.sayHi;
    // setTimeout(f, 1000);
    
  • Solutions:

    • A wrapper:

      setTimeout(function () {
        user.sayHi()
      }, 1000)
      
      • This works because it receives user from the outer lexical environment.
      setTimeout(() => user.sayHi(), 1000)
      
      • Looks fine but slightly vulnerable (if user is changed after the above use).
    • bind:

      • let boundFunc = func.bind(context)
      • Calling boundFunc is like func with a fixed this.
      • let sayHi = user.sayHi.bind(user)
    • bindAll (for binding all methods of an object):

      for (let key in user) {
        if (typeof user[key] == 'function') {
          user[key] = user[key].bind(user)
        }
      }
      
  • Partial function application:

    • It can take parameters as well.

    • let bound = func.bind(context, [arg1], [arg2] ...)

    • function mul(a, b) {
        return a * b
      }
      let double = mul.bind(null, 2) // Creates a new function that always has 'a' as 2
      alert(double(3)) // mul(2, 3) = 6
      alert(double(5)) // mul(2, 5) = 10
      
    • Partial application is useful when we have a very generic function and want a less universal variant of it for convenience.

  • Going partial without context:

    • The bind function does not allow to fix arguments without the context.
    • Function partial allows binding arguments without context.
    function partial(func, ...argsBound) {
      return function (...args) {
        // Wrapper function
        return func.call(this, ...argsBound, ...args)
      }
    }
    
    let user = {}
    user.sayNow = partial(user.say, someDate)
    user.sayNow('Hello')
    
    • When we fix some arguments of an existing function, the resulting (less universal) function is called partially applied or partial.
    • Partials are convenient when we don't want to repeat the same argument over and over again.

Arrow function revisited

  • We usually don't want to leave the current context; that's where arrow functions come in handy.
  • Arrow functions have no this.
  • Arrow functions do not run with "new".
  • The arrow function doesn't create any bind (no .bind).
  • Arrow functions have no arguments object.

Object Properties Configuration

  • Besides value, three special attributes (flags):

    • writable (value can't be changed if false).
    • enumerable (if true, listed in loops).
    • configurable (if true, property can be deleted, flags can be changed).
  • let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);

    {
      "value": "John",
      "writable": true,
      "enumerable": true,
      "configurable": true,
    }
    
  • Object.defineProperty(obj, propertyName, descriptor)

  • In non-strict mode, no errors occur when writing to non-writable properties, etc.

  • Flag-violating actions are just silently ignored in non-strict mode.

  • When enumerable: false:

    • Will not appear in for...in loop.
    • Will not appear in Object.keys().
  • Non-configurable (configurable: false) property cannot be deleted.

  • Making a property non-configurable is a one-way road. We cannot change it back with defineProperty.

  • Non-configurable imposes:

    • Can't change configurable flag.
    • Can't change enumerable flag.
    • Can't change writable: false to true (The other way around works).
    • Can't change get/set for an accessor property.
  • The idea of "configurable: false" is to prevent changes of property flags and its deletion, while allowing to change its value.

  • Object.defineProperties

    Object.defineProperties(obj, {
      prop1: descriptor1,
      prop2: descriptor2,
      Surname: { value: 'Smith', writable: false },
    })
    
  • If we want a better clone of an object, use: let clone = Object.defineProperties([], Object.getOwnPropertyDescriptors(obj));

  • Object.getOwnPropertyDescriptors returns all property descriptors including symbolic ones.

  • Sealing an object:

    • Object.preventExtensions(obj): Forbids the addition of new properties to the object.
    • Object.seal(obj): Forbids adding/removing of properties. Sets configurable: false for all existing properties.
    • Object.freeze(obj): Forbids adding/removing/changing of properties. Sets configurable: false and writable: false for all properties.
    • Object.isExtensible(obj)
    • Object.isSealed(obj)
    • Object.isFrozen(obj)

Getters and Setters

  • let obj = {
      get propName() {},
      set propName(value) {},
    }
    
  • let user = {
      name: 'Rakesh',
      surname: 'Tembhurne',
      get fullName() {
        return `${this.name} ${this.surname}`
      },
      set fullName(value) {
        ;[this.name, this.surname] = value.split(' ')
      },
    }
    
  • We don't call user.fullName as a function; we read it normally. The getter runs behind the scenes.

  • There will be an error when doing like: user.fullName = xxx if no setter is defined.

  • fullName is a virtual property; it is readable and writable with get() and set().

  • Accessor Descriptors

    • get
    • set
    • enumerable - same as for data properties.
    • configurable - same as for data properties.
  • A property can be either an accessor (has get/set methods) or a data property (has a value), not both.

    // Error: Invalid property descriptor
    Object.defineProperty({}, 'prop', {
      get() {
        return 1
      },
      value: 2, // Cannot have both get/set and value
    })
    

Prototypes, Inheritance

  • In JavaScript, objects have a special hidden property [[Prototype]], that is either null or an object. That object is called "prototype".
  • The property [[Prototype]] is internal and hidden.
  • Multiple ways to access [[Prototype]]:
    • rabbit.__proto__ = animal;
  • If animal has a lot of useful properties and methods, then they become automatically available in rabbit.
  • The prototype chain can be longer.
  • There are only two limitations:
    • The references cannot go in circles. (Error)
    • The value of __proto__ can be either null or an object. Other types are ignored.
  • There can be only one [[Prototype]]. An object may not inherit from two others.
  • __proto__ is a historical getter/setter for [[Prototype]].
  • __proto__ is not the same as the internal [[Prototype]] property. It's a getter/setter for [[Prototype]].
  • The __proto__ property is a bit outdated. Object.getPrototypeOf and Object.setPrototypeOf are used nowadays.
  • The prototype is only used for reading purposes. Writing/deleting works directly with the object.
  • Accessor properties are an exception to the above rule. An assignment is handled by a setter function.
  • this is not affected by the prototypes at all.
  • No matter where the method is found: in an object or its prototype. In a method call, this is always the object before the call.
  • When the inheriting object runs the inherited methods, they will modify only their own states, not the state of the base object.
  • Object.keys() only returns an object's own keys, but the for...in loop iterates over inherited properties too.
  • To exclude inherited properties, there is a built-in method obj.hasOwnProperty(key).
  • Why does hasOwnProperty not appear in for...in loop? The answer is – it's not enumerable. for...in only shows enumerable properties.

F.prototype

  • If F.prototype is an object, then the new operator uses it to set [[Prototype]] for the new object.

  • Setting Rabbit.prototype = animal literally states the following: "When a new Rabbit is created, assign its [[Prototype]] to animal".

    Rabbit
    ───────► prototype ───────► animal
                            eats: true
    
    [[Prototype]]
    rabbit ──────────┘
    name: "White Rabbit"
    
  • On the picture, "prototype" is a horizontal arrow, meaning a regular property, and [[Prototype]] is vertical, meaning the inheritance of rabbit from animal.

  • F.prototype property is only used when new F() is called; it assigns [[Prototype]] of the new object.

  • If, after the creation, F.prototype property changes (F.prototype = <new object>), then new objects created by new F will have another object [[Prototype]], but already existing objects keep the old one.

Default F.prototype, constructor property

  • F.prototype property (don't mistake it for [[Prototype]]) sets [[Prototype]] of new objects when new F() is called.

  • The value of F.prototype should be either an object or null; other values won't work.

  • The "prototype" property only has such a special effect when set on a constructor function and invoked with new.

  • Every function has the "prototype" property even if we don't supply it.

  • The default prototype is an object with the only property constructor that points back to the function itself.

    function Rabbit() {}
    // Rabbit.prototype = { constructor: Rabbit }
    alert(Rabbit.prototype.constructor === Rabbit) // True
    
    function Rabbit() {}
    let rabbit = new Rabbit()
    alert(rabbit.constructor == Rabbit) // true
    
    let rabbit2 = new rabbit.constructor('White Rabbit') // Creates new rabbit using constructor
    let rabbit3 = new Rabbit('Black Rabbit') // Creates new rabbit using constructor
    
  • That's handy when we have an object, don't know which constructor was used for it, and we need to create another one of the same kind.

  • Javascript does not ensure the right "constructor" value. It's on us after creation.

Native Prototypes

  • Object.prototype

    • The short notation obj = {} is the same as obj = new Object().

    • When new Object() is called (or a literal object {...} is created), the [[Prototype]] of it is set to Object.prototype.

    • let obj = {}
      alert(obj.__proto__ === Object.prototype) // true
      alert(obj.toString === obj.__proto__.toString) //true
      alert(obj.toString === Object.prototype.toString) //true
      
  • Other Built-in Prototypes

    graph TD
        subgraph Object Hierarchy
            A[null] --> B[[Prototype]]
            B --> C[Object.prototype]
            C --> D[toString: function]
            C --> E[other object methods]
        end
    
        subgraph Array Hierarchy
            F[[Prototype]] --> C
            G[Array.prototype] --> F
            G --> H[slice: function]
            G --> I[other array methods]
            J[[1, 2, 3]] --> G
        end
    
        subgraph Function Hierarchy
            K[[Prototype]] --> C
            L[Function.prototype] --> K
            L --> M[call: function]
            L --> N[other function methods]
            O[function f(args) { ... }] --> L
        end
    
        subgraph Number Hierarchy
            P[[Prototype]] --> C
            Q[Number.prototype] --> P
            Q --> R[toFixed: function]
            Q --> S[other number methods]
            T[5] --> Q
        end
    
  • All of the built-in prototypes have Object.prototype on the top.

  • let arr = [1, 2, 3];

    • alert(arr.__proto__ === Array.prototype); // true
    • // then from Object.prototype?
    • alert(arr.__proto__.__proto__ === Object.prototype); // true
    • // and null on the top.
    • alert(arr.__proto__.__proto__.__proto__); // null

Primitives (cont.)

  • The most intricate thing happens with strings, numbers, and booleans.
  • As they are not objects, a temporary wrapper object is created using built-in constructors: String, Number, and Boolean. They provide methods and disappear.
  • Values null and undefined have no object wrappers.

Changing native prototypes

  • Native prototypes can be modified.

  • Prototypes are global, so it's easy to get conflicts. If two libraries add the same method, it can cause issues.

  • In modern programming, there is only one case where modifying native prototypes is approved: polyfilling.

    if (!String.prototype.repeat) {
      String.prototype.repeat = function (n) {
        /* ... implementation ... */
      }
    }
    
  • Object borrowing:

    let obj = {}
    obj.join = Array.prototype.join // Borrowing join method
    alert(obj.join(',')) // Works on obj if obj has a .length property or similar structure
    
  • All built-in objects follow the same pattern:

    • The methods are stored in the prototype (Array.prototype, Object.prototype, Date.prototype, etc.).
    • The object itself stores only the data.
  • Primitives also store methods in prototypes of wrapper objects: Number.prototype, String.prototype, and Boolean.prototype.

  • Only undefined and null do not have wrapper objects.

  • Built-in prototypes can be modified or populated with new methods. But it's not recommended to change them. The only allowable case is probably when we add a new standard, but it's not yet supported by the JavaScript engine.

Prototype Methods, objects without __proto__

  • The __proto__ is considered outdated and somewhat deprecated.

  • Modern methods to get/set prototypes are:

    • Object.getPrototypeOf(obj) - returns [[Prototype]] of obj.
    • Object.setPrototypeOf(obj, proto) - sets the [[Prototype]] of obj to proto.
    • Object.create(proto, [descriptors]) - creates an empty object with a given proto as [[Prototype]] and optional property descriptors.
  • Object.create() has an optional second argument: property descriptors. We can provide additional properties to the new object there.

    let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj))
    
  • Don't change [[Prototype]] on existing objects if speed matters.

  • Object.create(null) creates an empty object without a prototype ([[Prototype]] is null).

  • The built-in __proto__ getter/setter is unsafe if we'd want to put user-generated keys into an object.

  • We can use Object.create(null) to create a "very plain" object without __proto__, or stick to Map object for that.

  • Object.create provides an easy way to shallow-copy an object with all descriptors.

  • __proto__ is a getter/setter for [[Prototype]] and resides in Object.prototype, just like other methods.

Classes

  • The class construct introduces great new features which are useful for object-oriented programming.

  • class User {
      constructor() {}
      sayHi() {}
      method2() {}
    }
    
  • There is no comma between class methods. Within the class, no comma is required.

  • What class User { ... } construct really does is:

    • Creates a function named User, that becomes the result of the class declaration.
    • The function code is taken from the constructor method.
    • Stores class methods, such as sayHi() in User.prototype.
  • alert(typeof User) // function

  • alert(User === User.prototype.constructor) // true

  • alert(Object.getOwnPropertyNames(User.prototype)) // constructor, sayHi, method2

  • Difference between function User(){} and class User {}

    • A function created by a class is labelled by a special internal property [[IsClassConstructor]]: true.
    • String representation of a class starts with "class User {...]".
    • User() // Error: class constructor User cannot be invoked without "new".
    • Class methods are non-enumerable. Class definition sets enumerable: false for all methods in "prototype".
    • So, class methods won't be shown in for...in loop.
    • Classes always "use strict".
  • Just like functions, classes can be defined inside another expression: let User = class { ... };.

  • If a class expression has a name, it's visible inside the class only: let User = class MyClass{...}; // MyClass can be used inside class.

  • Classes can be created on-demand: return class { ... };.

Class Fields

  • Previously, our classes only had methods. Class fields are a recent addition.

  • The important difference of class fields is that they are set on individual objects, not User.prototype.

    class User {
      name = 'John' // A public class field
    }
    let user = new User()
    alert(user.name) // John
    alert(user.prototype.name) // undefined
    
  • Methods like click() { alert(this.value); } have a problem of losing this, which can be fixed with:

    • Pass a wrapper function, such as setTimeout(() => buttons.click(), 1000).
    • Bind the method to the object, e.g., in the constructor.
  • MyClass is technically a function, while methods, getters, and setters are written to MyClass.prototype.

Class Inheritance

  • "extends" keyword is used: class Rabbit extends Animal.

  • Any expression is allowed after "extends":

    function f(phrase) {
      return class {
        sayHi() {
          alert(phrase)
        }
      }
    }
    class User extends f('Hello') {}
    
  • By default, all methods that are not specified in class Rabbit are taken directly "as is" from class Animal.

  • In order to extend the functionality of methods or tweak them, we use "super".

    • super.method(...) to call a parent method.
    • super(...) to call a parent constructor.
  • Arrow functions have no super.

  • If a class extends another class and has no constructor, then the following empty constructor is generated:

    class Rabbit extends Animal {
      constructor(...args) {
        super(...args)
      }
    }
    
  • That happens if we don't write a constructor of our own.

  • Constructors in inheriting classes must call super(...), and do it before using this.

  • Inheriting class (so-called derived constructor) has a special internal property [[ConstructorKind]]: "derived".

  • "derived" label affects behavior with "new":

    • When a regular function is executed with new, it creates an empty object and assigns it to this.
    • But when a derived constructor runs, it doesn't do this. It expects the parent constructor to do this job.
  • We can override not only methods, but also class fields.

  • Parent constructor ALWAYS uses its own field value, not the overridden one.

Static properties and methods

  • We can also assign a method to the class function itself, not to its "prototype". Such methods are called static.

    class User {
      static staticMethod() {}
    }
    
  • Above is the same as:

    class User {}
    User.staticMethod = function () {}
    
  • Usually, static methods are used to implement functions that belong to the class, but not to a particular object of it.

  • Technically, a static declaration is the same as assigning to the class itself.

    • MyClass.property = ...
    • MyClass.method = ...
  • For class B extends A, the prototype of class B itself points to A. B.[[Prototype]] = A.

  • So if a field is not found in B, the search continues in A.

Protected fields

  • Protected properties are usually prefixed with an underscore (_).
  • This is not enforced on the language level, but there's a well-known convention between programmers that such properties and methods should not be accessed from the outside.
  • Protected fields are naturally inheritable. Unlike private ones that we will see.

Private fields

  • It's a recent addition.
  • Privates should start with #.
  • They are only accessible from inside the class.
  • # is a special sign that the field is private.
  • Private fields do not conflict with public ones. We can have both #waterAmount and waterAmount (public property).
  • Unlike protected ones, private fields are enforced by the language itself.
  • Fields like this['#name'] doesn't work.

Extending built-in classes

  • class PowerArray extends Array {
      isEmpty() {
        return this.length === 0
      }
    }
    
  • When arr.filter() is called, it internally creates a new array of results using exactly arr.constructor, not basic Array.

  • That's actually very cool, because we can keep using PowerArray methods further on the result.

  • Even more, we can customize this behavior by using Symbol.species:

    class PowerArray extends Array {
      static get [Symbol.species]() {
        return Array
      }
    }
    
  • Now, .filter() returns Array. So the extended functionality is not passed any further.

  • Other collections such as Map, Set work alike. They also use Symbol.species.

  • There is no link between Date and Object. They are independent; only Date.prototype inherits from Object.prototype.

  • obj instanceof class returns true if obj belongs to the Class or a class inheriting from it.

    • arr instanceof Array // true
    • arr instanceof Object // true
  • We can set custom logic in the static method Symbol.hasInstance:

    class Animal {
      static [Symbol.hasInstance](obj) {
        if (obj.canEdit) return true
      }
    }
    // Usage: new Animal() instanceof Animal // true
    // { canEdit: true } instanceof Animal // true
    
  • Most classes do not have Symbol.hasInstance; then it's checked like this:

    • obj.__proto__ === Class.prototype?
    • obj.__proto__.__proto__ === Class.prototype?
    • obj.__proto__.__proto__.__proto__ === Class.prototype?
  • There is also a method objA.isPrototypeOf(objB), which returns true/false.

  • obj instanceof Class can be rephrased as Class.prototype.isPrototypeOf(obj).

  • Alternative to instanceof is extended typeof:

    • alert({}.toString.call(value))
    • For a number, it will be [object Number].
    • For a boolean, it will be [object Boolean].
    • For null: [object Null].
    • For undefined: [object Undefined].
    • For arrays: [object Array].
    let objectToString = Object.prototype.toString
    let arr = []
    alert(objectToString.call(arr)) // [object Array]
    
  • The behavior of Object.prototype.toString can be customized using a special object property Symbol.toStringTag.

    let User = {
      [Symbol.toStringTag]: 'User',
    }
    alert({}.toString.call(User)) // [object User]
    alert(window[Symbol.toStringTag]) // Window
    alert(XMLHttpRequest.prototype[Symbol.toStringTag]) // XMLHttpRequest (for browser environments)
    alert({}.toString.call(window)) // [object Window]
    alert({}.toString.call(new XMLHttpRequest())) // [object XMLHttpRequest]
    
  • We can use {}.toString.call instead of instanceof for built-in objects when we want to get the type as a string rather than just to check.

Mixins

  • There can be only one [[Prototype]] for an object.

  • A mixin is a class containing methods that can be used by other classes without a need to inherit from it.

    // Example Mixin
    let sayHiMixin = {
      sayHi() {
        alert('Hi!')
      },
      sayBye() {
        alert('Bye!')
      },
    }
    
    // Use the mixin with a class
    class User extends Params {
      /* ... */
    } // Assuming Params is some base class
    Object.assign(User.prototype, sayHiMixin)
    

Error Handling

  • Usually, a script "dies" in case of an error, printing it to the console.
  • But try...catch allows us to "catch" errors.
  • The try block is executed. If there are no errors, then the catch block is ignored.
  • If there is an error, then the catch block is executed.
  • try...catch does not kill the script; we can handle things in the catch block.
  • try...catch only works for runtime errors.
  • For try...catch to work, the code must be runnable. In other words, it should be valid JavaScript.
  • It won't work if the code is syntactically wrong, for instance, it has unmatched curly braces.
  • try...catch can only handle errors that occur in valid code - such errors are called runtime errors or sometimes exceptions.
  • try...catch works synchronously. If an exception happens in "scheduled" code like setTimeout, then try...catch won't catch it.
  • To catch an exception inside a scheduled function, try...catch must be inside that function.
  • Error Object
    • It has two main components: name and message.
    • stack is also available for debugging purposes.
    • Recent addition: If we don't need error details, we may omit (err) from catch: try { /* ... */ } catch {}.
  • Throw Operator
    • Technically, we can use anything as an error object.
    • It's better to be an object, preferably with name and message properties (for compatibility).
  • Built-in constructors for errors:
    • Error
    • SyntaxError
    • ReferenceError
    • TypeError
  • For built-in errors, the name property is exactly the same as the name of the constructor. And message is taken from the argument.
  • We can get the error class name from err.name property. All native errors have it. Another option is to read err.constructor.name.
  • The code has two ways of execution when using try...catch...finally:
    • If you answer "Yes" to "Make an error?", then try => catch => finally.
    • If you say "No", then try => finally.
  • The function may finish with return or throw; that doesn't matter. The finally clause executes in both cases.
  • Variables are local inside try...catch...finally.
  • The finally clause works for any exit from try...catch, that includes an explicit return.
  • The try...finally construct, without a catch clause, is also useful. We apply it when we don't want to handle errors here, but want to be sure that processes that we started are finalized.
  • Global Catch
    • Node.js: process.on("uncaughtException", handler);
    • Browser: window.onerror = function (message, url, line, col, error) { ... };
  • Rethrowing is a very important pattern of error handling: a catch block usually expects and knows how to handle the particular error type, so it should rethrow errors it doesn't know.
  • We can inherit from Error and other built-in error classes normally. We just need to take care of the name property and don't forget to call super.
  • We can use instanceof to check for particular errors. It also works with inheritance. But sometimes we have an error object coming from a 3rd-party library and there's no easy way to get its class. Then name property can be used for such checks.
  • Wrapping exceptions is a widespread technique: a function handles low-level exceptions and creates higher-level errors instead of various low-level ones. Low-level exceptions sometimes become properties of that object like err.cause in the examples above, but that's not strictly required.

Promises, async/await

  • Error-first callback style:

    • The first argument of the callback is reserved for an error if it occurs. Then callback(err) is called.
    • The second argument (and the next one if needed) are for the successful result. Then callback(null, result, result2,...) is called.
  • Pyramid of doom or also known as callback hell.

  • The function passed to new Promise is called executor. When new Promise is created, the executor runs automatically.

  • After the executor is finished:

    • resolve(value)
    • reject(error)
  • The promise object returned by the new Promise constructor has these internal properties:

    • State - initially "pending", then changes to either "fulfilled" when resolve is called or "rejected" when reject is called.
    • Result - initially undefined. Then changes to value when resolve(value) is called or error when reject(error) is called.
  • There can be only a single result or an error.

  • The executor should call only one resolve or one reject.

  • Any state change is final. All further calls of resolve and reject are ignored.

  • resolve/reject expect only one argument (or none) and will ignore additional arguments.

  • reject can be used with any type of argument, but an Error object is recommended.

  • We can call resolve or reject immediately, without async operations.

  • The state and result are internal. The properties state and result of the Promise object are internal. We can't directly access them.

  • Consumers: then, catch, finally

    • promise.then(function(result){...}, function(error) {...})
    • The call .catch(f) is a complete analog of .then(null, f). It's just a shorthand.
    • The call .finally(f) is similar to .then(f, f) in the sense that f always runs when the promise is settled: be it resolve or reject.
  • The finally isn't exactly an alias of then(f, f) though. There are few subtle differences:

    • A finally handler has no arguments. In finally, we don't know whether the promise is successful or not. That's all right, as our task is usually to perform "general" finalizing procedures.
    • A finally handler passes through results and errors to the next handler. We can use .then or .catch after .finally.
  • The finally is not meant to process a promise result. So it passes it through.

  • If the promise is pending, .then/catch/finally handlers wait for it.

  • If a .then (or .catch/finally doesn't matter) handler returns a promise, the rest of the chain waits until it settles. When it does, its result is passed further.

  • Promise chains are great at error handling. When a promise rejects, the control jumps to the closest rejection handler.

  • The code of a promise executor and handler has an "invisible try...catch" around it.

  • .catch at the end of the chain is similar to try...catch.

  • If we throw inside .catch, then the control goes to the next closest error handler. And if we handle the error and finish it normally, then it continues to the next closest successful .then handler.

    new Promise(...).then(...).catch(...).then(...).catch(...);
    
  • Just like in try...catch, a similar thing happens with unhandled promises.

  • Promisification - it's the conversion of a function that accepts a callback into a function that returns a promise.

    function promisify(f, manyArgs = false) {
      return function (...args) {
        return new Promise((resolve, reject) => {
          function callback(err, ...results) {
            // our custom callback for f
            if (err) {
              reject(err)
            } else {
              // resolve with all callback results if manyArgs is specified
              resolve(manyArgs ? results : results[0])
            }
          }
          args.push(callback)
          f.call(this, ...args)
        })
      }
    }
    
    // Usage:
    // let f = promisify(originalCallbackFunction, true);
    // f(...).then(arrayOfResults => ..., err => ...);
    
  • Promisification is not a total replacement for callbacks.

  • A promise may have only one result, but a callback may technically be called many times.

  • So promisification is only meant for functions that call the callback once. Further calls will be ignored.

  • Microtasks

    • Promise handlers (.then, .catch, .finally) are always asynchronous.
    • Asynchronous tasks need proper management - ECMA uses an internal queue: Promise Jobs.
    • The queue is first-in-first-out: tasks enqueued first are run first.
    • Execution of a task is initiated only when nothing else is running.
  • Unhandled Rejection

    • An "unhandled rejection" (with unhandledRejection event) occurs when a promise error is not handled at the end of the microtask queue.
    • unhandledRejection is generated when the microtask queue is complete.
  • Promise handling is always asynchronous, as all promise actions pass through the internal "promise jobs" queue (microtask queue).

  • So .then/catch/finally handlers are always called after the current code is finished.

  • The concept of microtask is closely related with the "event loop".

  • async/await

    • The word "async" before a function means one simple thing: a function always returns a promise. Other values are wrapped in a resolved promise automatically.

      async function f() {
        return 1
      }
      f().then(alert) // Shows 1
      
    • async ensures that the function returns a promise, and wraps non-promises in it.

    • await literally suspends the function execution until the promise settles, and then resumes it with the promise result.

    • Can't use await in a non-async function; there would be a syntax error if used.

    • await only works inside an async function.

    • await won't work in top-level code directly, so we have to wrap it up in an anonymous async function or IIFE.

    • await accepts "thenables" (objects with a .then method).

Generators

  • Regular functions return only one, single value (or nothing).

  • Generators can return (yield) multiple values, one after another, on demand.

  • They work great with iterables, allowing to create data streams with ease.

  • To create a generator, we use function* f() { ... }.

  • Generators behave differently from regular functions. When such a function is called, it doesn't run its code. Instead, it returns a special object, called a "generator object", to manage the execution ([object Generator]).

  • The main method of a generator is next(). When called, it runs the execution until the nearest yield <value>.

  • next() always returns an object with two properties:

    • value: the yielded value.
    • done: true if the function code has finished, otherwise false.
  • Two syntaxes for generators, but the first is preferred:

    • function* f() { ... }
    • function *f() { ... }
  • Generators are iterables.

    for (let value of generator) {
      alert(value)
    }
    
  • Looks a lot nicer than calling .next().value.

  • for...of iteration ignores the last value when done: true.

  • If we want all results to be shown by for...of, we must return them with yield.

    let sequence = [0, ...generatorSequence()] // Converts iterable to array
    
  • ...generatorSequence() turns the iterable generator object into an array of items.

  • "yield" is a two-way street. It not only returns the result to the outside, but also can pass the value inside that generator.

  • The first call generator.next() should always be made without arguments.

  • To pass an error into a yield, we should call generator.throw(err).

  • In JavaScript, generators are rarely used. But sometimes they come in handy, because the ability of a function to exchange data with the calling code during the execution is quite unique.

  • Generators are great for making iterable objects.

  • To use async iterables, use Symbol.asyncIterator instead of Symbol.iterator.

  • To iterate over async iterables, we should use a for await (let item of iterable) loop.

  • The spread syntax ... doesn't work asynchronously.

  • For async generators, generator.next() method is asynchronous; it returns promises.

Modules

  • As our apps grow bigger, we want to split them into multiple files, so-called "modules".
  • They may contain a class or a library of functions for a specific purpose.
  • Some types of module systems (historically):
    • AMD
    • CommonJS
    • UMD
  • The language-level module system appeared in 2015.
  • A module is just a file; one script is one module, as simple as that.
  • Modules use special directives export and import.
  • export keyword labels variables and functions that should be accessible from outside the current module.
  • import allows the import of functionality from other modules.
  • As modules support special keywords and features, we must tell the browser that a script should be treated as a module, by using the attribute <script type="module">.
  • Modules work only via HTTP(s), not locally. So file:// protocol won't work.
  • Modules always work in strict mode ("use strict").
  • Each module has its own top-level scope.
  • Two scripts on the same page, both type="module", don't see each other's top-level variables.
  • To make a variable global, modules can use window.variable = ..., which can be then used by other modules. This is not recommended.
  • A module code is evaluated only the first time when imported.
  • There's a rule: Top-level module code should be used for initialization/creation of module-specific internal data structures.
  • export let admin = { name: "John" };
  • If this module is imported from multiple files, the module is only evaluated the first time. The admin object is created and then passed to all further importers.
  • Exports are generated and then they are shared between importers, so if something changes the admin object, other modules will see that.
  • Such behavior is actually very convenient, because it allows us to configure modules.
  • import.meta object contains information about the current module. Its content depends upon the environment.
  • In a module, top-level this is undefined.
  • Module scripts are deferred, same effect as defer attribute.
    • <script type="module" src="..."> doesn't block HTML processing; they load in parallel with other resources.
    • Module scripts wait until the HTML document is fully ready.
    • Relative order of scripts is maintained; scripts that go first execute first.
    • <script type="module"> ... </script> vs <script>: The second script will work first, as the module will load after HTML is fully loaded.
  • External scripts with the same src run only once.
  • External scripts that are fetched from another origin require CORS headers.
  • No bare modules are allowed. In browsers, import must get either a relative or absolute URL. Modules without any path are called "bare" modules. Such modules are not allowed in import.
  • Most JS style guides don't recommend semicolons after function and class declarations.
  • If there's a lot to import, we can use import * as obj.
  • We can export at the end or start of the script: export { funOne, funTwo };.
  • If an exported function is not imported, the optimizer will remove that code from bundled code. This is called "tree-shaking".
  • "as" can be used in export like export {sayHi as hi, sayBye as bye} and in import like import { sayHi as hi, sayBye as bye } from './say.js';.
  • Two kinds of modules:
    • Modules that contain a library, pack of functions.
    • Modules that contain a single entity.
  • There may be only one export default per file.
  • When export class User {...} is used, we have to import like import { User } from ....
  • When export default class User {...} is used, we have to import like import User from ....
  • A module either has named exports or the default one.
  • To export a function separately from its definition: export { sayHi as default };.
  • To import the default export along with named ones: import { default as User, sayHi } from './user.js';
  • To import everything * as an object, then the default property is exactly the default export: import * as user from './user.js';
    • let User = user.default;
    • new User("John");
  • Named exports are explicit; they exactly name what they import, so we can have that information from them; that's a good thing.
  • While for a default export, we always choose the name when importing:
    • import User from './user.js';
    • import MyUser from './user.js';
  • There's a rule that imported variables should correspond to the file name:
    • import User from './user.js';
    • import LoginForm from './loginForm.js';
  • Re-exporting:
    • export {sayHi} from './say.js';
    • export {default as User} from './user.js';
    • export {login, logout} from './user.js';
  • The syntax export ... from ... is just a shorter notation for such import-export.
  • Re-exporting the default export:
    • export User from './user.js'; won't work directly. That would lead to a syntax error. We have to write export {default as User} from './user.js';.
  • Summary of Exports:
    • export * from './user.js'; re-exports only named exports, but ignores the default one.
    • export {default} from './user.js';
    • Before declaration of a class/function/..:
      • export [default] class/function/variable
    • Standalone export:
      • export {x [as y], ...}
    • Re-export:
      • export { x [as y], ... } from "module"
      • export * from "module" (doesn't re-export default)
      • export {default [as y]} from "module" (re-export default)
    • Importing named exports:
      • import {x [as y], ...} from "module"
    • Importing the default export:
      • import x from "module"
      • import {default as x} from "module"
    • Import all:
      • import * as obj from "module"
    • Import the module (its code runs), but do not assign any of its exports to variables:
      • import "module"
  • The import/export statements don't work inside {...} code blocks.
  • The import() expression loads the module and returns a promise that resolves into a module object that contains all its exports. It can be called from any place in the code.
    • import(modulePath).then(obj => {...}).catch(err => {...})
    • let module = await import(modulePath)
    • let {hi, bye} = await import('./say.js')

Anomalies

  • parseInt(0.00001) == parseInt(String(0.00001)) == parseInt('0.00001') ==> 0
  • parseInt(0.00000001) == parseInt(String(0.00000001)) == parseInt('1e-8') ==> 1

parseInt / parseFloat Table

valueparseIntparseFloat
'564.239'564564.239
'+564.239'564564.239
'-564.239'-564-564.239
'564FGL'564564
'FGL'NaNNaN
'4px 9px'44
'(786)'NaNNaN
'0xF'150
'056'5656
564564564

Reference for some comparison behavior: https://javascript.plainenglish.io/interviewer-can-a-1-a-2-a-3-ever-evaluate-to-true-in-javascript-d2329e693cde

Comparison Table (A vs B)

B \ AUndefinedNullNumberStringBooleanObject
UndefinedtruetruefalsefalsefalseIsFalsy(B)
NulltruetruefalsefalsefalseIsFalsy(B)
NumberfalsefalseA === BA === ToNumber(B)A === ToNumber(B)A === ToPrimitive(B)
StringfalsefalseToNumber(A) === BA === BToNumber(A) === ToNumber(B)ToNumber(A) === ToPrimitive(B)
BooleanfalsefalseToNumber(A) === BToNumber(A) === ToNumber(B)A === BToNumber(A) === ToPrimitive(B)
ObjectfalsefalseToPrimitive(A) === BToPrimitive(A) === ToNumber(B)ToPrimitive(A) === ToNumber(B)A === B

Proxy and Reflect

  • Proxy: A special object that wraps another object (target) and intercepts fundamental operations applied to it.
    • new Proxy(target, handler): target is the object to be proxied; handler is an object containing "traps" (methods) to intercept operations.
    • Traps: Over 13 methods (get, set, apply, construct, has, deleteProperty, etc.) that define custom behavior for operations.
    • Use Cases: Validation, logging, access control, data binding, memoization.
    • Allows redefining almost any interaction with an object.
  • Reflect: A built-in object that provides methods for interceptable JavaScript operations.
    • Not a constructor; all its methods are static.
    • Reflect.get(target, propertyKey, receiver): Performs the default behavior for getting a property.
    • Reflect.set(target, propertyKey, value, receiver): Performs the default behavior for setting a property.
    • Reflect.apply(target, thisArgument, argumentsList): Performs the default behavior for function calls.
    • Purpose:
      • Provides a functional API for internal JavaScript operations.
      • Enables Proxy traps to easily call the original behavior of an operation.
      • Offers a safer way to perform operations that might otherwise throw errors in strict mode (e.g., Reflect.set returns true/false).
    • Often used in conjunction with Proxy to provide default or extended trap logic.

Eval: run a code string

  • eval(codeString): Executes a string as JavaScript code.
  • Dangers:
    • Security Risk: Running code from untrusted sources is a major security vulnerability as it can execute malicious scripts.
    • Performance: Code executed with eval cannot be optimized by JS engines as efficiently as regular code, leading to slower execution.
    • Debugging: Difficult to debug because the code is not known until runtime.
    • Scope Pollution: In non-strict mode, variables declared with var or functions can leak into the surrounding scope. In strict mode, they are scoped to the eval context.
  • Alternatives:
    • new Function(args, codeString): A safer alternative for executing code strings, as it runs in its own global lexical environment and does not have access to local variables of the calling context.
    • JSON.parse() for parsing data (not code).
    • WebAssembly for performance-critical compiled code.
  • General Advice: Avoid eval whenever possible. Only use if the input string is fully controlled and trusted.

Currying

  • Currying: A transformation of functions that converts a function callable as f(a, b, c) into a sequence of functions callable as f(a)(b)(c).

  • It does not call the function; it transforms it into a new callable form.

  • Mechanism: Each call in the sequence returns a new function that expects the next argument, until all arguments are provided, at which point the original function is finally executed.

  • Benefits:

    • Reusability: Allows creating specialized functions from more general ones by fixing some arguments.
    • Composability: Easier to compose functions together, as each step returns a new function.
    • Delayed Execution: Computation is deferred until all necessary inputs are available.
    • Readability: Can lead to more readable code for certain patterns.
  • Implementation Example:

    function curry(func) {
      return function curried(...args) {
        // If all arguments are collected, call the original function
        if (args.length >= func.length) {
          return func.apply(this, args)
        } else {
          // Otherwise, return a new function that collects more arguments
          return function (...args2) {
            return curried.apply(this, args.concat(args2))
          }
        }
      }
    }
    
    // Example Usage
    function sum(a, b, c) {
      return a + b + c
    }
    let curriedSum = curry(sum)
    
    console.log(curriedSum(1, 2, 3)) // 6
    console.log(curriedSum(1)(2, 3)) // 6
    console.log(curriedSum(1)(2)(3)) // 6
    
  • func.length: A function property that returns the number of arguments the function expects.

Reference Type

  • Reference Type: A special internal JavaScript type that describes the result of a property access operation (e.g., obj.prop or arr[index]).
  • It is not directly accessible or manipulable by developers in their code. It's an internal specification mechanism of the engine.
  • Components: A Reference Type value internally consists of:
    • base: The object on which the property was accessed.
    • propertyName: The name of the property.
    • strict: A boolean flag indicating if the operation occurred in strict mode.
  • Purpose & Significance:
    • Crucial for correctly handling property assignments: When obj.prop = value is executed, the engine first evaluates obj.prop to a Reference Type, then uses its base (obj) and propertyName (prop) to perform the assignment.
    • Fundamental to this binding in method calls: When obj.method() is called, the expression obj.method evaluates to a Reference Type where base is obj. This base value is then used to set this inside the method function.
  • It helps explain the subtle rules of this and property operations that might seem magical otherwise.

BigInt

  • BigInt: A primitive data type in JavaScript introduced to represent whole numbers larger than 2^53 - 1 (which is Number.MAX_SAFE_INTEGER).
  • Declaration:
    • By appending n to an integer literal: 12345678901234567890n
    • Using the BigInt() constructor: BigInt("123")
  • Key Characteristics:
    • Can represent integers of arbitrary precision (limited only by available memory).
    • Not interchangeable with Number type: Mixing BigInt and Number in most arithmetic operations will throw a TypeError unless one is explicitly converted.
    • Comparison: Standard comparisons (==, ===, <, >) work between BigInt and Number. === requires both value and type to be the same.
    • Division (/): BigInt division truncates any fractional part (e.g., 5n / 2n results in 2n).
    • Supports bitwise operations (&, |, ^, ~, <<, >>, >>>).
  • Use Cases: Critical for applications requiring very large integer IDs, high-precision timestamps, or cryptographic operations where standard Number precision is insufficient.
  • Performance: Operations on BigInt can be slower than Number operations due to the complexity of handling arbitrary precision.

Unicode, String Internals

  • String Internals (UTF-16): JavaScript strings are internally represented using UTF-16 encoding.
    • Each character is stored as a 16-bit code unit.
    • Characters in the Basic Multilingual Plane (BMP) (most common characters) use one 16-bit code unit.
    • Characters outside the BMP (e.g., many emojis, ancient scripts) are represented by surrogate pairs, which are two 16-bit code units.
  • Unicode: A universal character encoding standard that assigns a unique number (code point) to every character.
  • String Length and Iteration:
    • str.length: Returns the number of UTF-16 code units. For characters represented by surrogate pairs, length will count them as 2.
    • for...of loop: Correctly iterates over actual Unicode characters (code points), handling surrogate pairs as a single character.
    • str.charCodeAt(pos): Returns the UTF-16 code unit at the given position. For a surrogate pair, it returns only the first unit.
    • str.codePointAt(pos): Returns the full Unicode code point at the given position, correctly handling surrogate pairs. This is the recommended method for character-based operations.
    • String.fromCharCode(codeUnit1, ...): Creates a string from one or more UTF-16 code units.
    • String.fromCodePoint(codePoint1, ...): Creates a string from one or more Unicode code points.
  • Normalization (str.normalize()): Converts a string to a canonical form, resolving different Unicode representations of the same character (e.g., é can be e + combining acute accent or a single precomposed character é). Important for correct string comparisons.

WeakRef and FinalizationRegistry

  • WeakRef (Weak Reference): A special object that allows you to hold a weak reference to another object.
    • new WeakRef(object): Creates a weak reference to object.
    • weakRef.deref(): Returns the referenced object, or undefined if the object has been garbage collected.
    • Key Concept: A weak reference does not prevent the referenced object from being garbage collected if there are no other strong references to it.
    • Use Cases: Caching results or associating extra data with an object without preventing its garbage collection.
    • Caution: The exact timing of garbage collection is non-deterministic; do not rely on a WeakRef for critical program logic.
  • FinalizationRegistry: An object that allows you to register objects to be notified when they are garbage collected.
    • new FinalizationRegistry(callback): Creates a registry. The callback function is invoked when a registered object is garbage collected.
    • registry.register(object, heldValue, [unregisterToken]): Registers an object for finalization.
      • heldValue: A value passed to the callback when object is collected. This value must be a primitive or strongly referenced.
      • unregisterToken: An optional token to explicitly unregister this specific registration later.
    • registry.unregister(unregisterToken): Removes a registration associated with the token.
    • Key Concept: Provides a way to perform cleanup tasks or release external resources (e.g., closing a file handle) when an object becomes unreachable.
    • Caution:
      • The callback is called after the object is garbage collected, and the timing is non-deterministic. It might be called much later or not at all (e.g., if the program exits).
      • Avoid creating new strong references to the collected object within the callback, as this can resurrect it and cause memory issues.
      • Cleanup code in the callback should be minimal and avoid heavy operations.
  • Combined Use: WeakRef can be used to check if an object still exists; FinalizationRegistry can be used to react when it is confirmed to be gone. Both are advanced, low-level tools primarily for library authors dealing with memory management.

Related Posts