Chapter 8

JavaScript Fundamentals

Values, types, functions, scope, objects, arrays and modules.

8. JavaScript Fundamentals

JavaScript is the programming language of the browser and a common language for server-side tools and applications. Writing reliable JavaScript starts with understanding what values are, how expressions convert between types, where variables are visible, and how functions and collections behave. Those details affect everything from a button click to an API response.

This chapter builds a practical mental model of JavaScript's primitive and reference values, equality and coercion, function forms, scope, objects, arrays, and ES modules. The examples use modern JavaScript and favor explicit behavior over clever shorthand.

Overview & Learning Objectives

By the end of this chapter, you will be able to:

  1. Distinguish primitive values from objects and explain reference sharing.
  2. Predict strict equality, coercion, and truthiness in common expressions.
  3. Choose appropriate function forms and use parameters safely.
  4. Explain global, function, and block scope, including the temporal dead zone.
  5. Read and write objects using destructuring, spread, optional chaining, and nullish coalescing.
  6. Transform arrays with map, filter, reduce, and flatMap without unintended mutation.
  7. Organize code with ES module imports and exports.
  8. Diagnose mutation and shared-reference bugs.

Values, Types, and Assignment

Primitive values

JavaScript has seven primitive types:

  • undefined: a missing or unassigned value.
  • null: an intentional absence of an object-like value.
  • boolean: true or false.
  • number: floating-point numbers, including NaN, Infinity, and -Infinity.
  • bigint: integers larger than the safe range of number.
  • string: text.
  • symbol: unique identifiers, often used as object property keys.

Primitives are immutable values. An expression cannot change the contents of an existing string or number. Operations produce new values:

js
let title = "Web";
title.toUpperCase(); // produces "WEB"; title remains "Web"
title = title.toUpperCase(); // assignment makes title refer to "WEB"

Variables are bindings to values, not boxes that guarantee the value's type. A let variable can be assigned another type:

js
let result = 42;
result = "complete";

const prevents reassignment of the binding, not mutation of an object referenced by that binding. This distinction becomes important when working with arrays and objects.

Objects and reference identity

Objects include plain objects, arrays, functions, dates, maps, sets, and many other structures. Variables hold a reference to an object. Assigning that variable to another copies the reference, not the entire object:

js
const first = { name: "Ari" };
const second = first;

second.name = "Bea";
console.log(first.name); // "Bea"
console.log(first === second); // true

Both bindings reach the same object. By contrast, two separately created objects are not strictly equal, even if their properties match:

js
({ count: 1 }) === ({ count: 1 }); // false

Equality for objects asks whether both sides refer to the same object, not whether their contents are structurally identical. If structural comparison is required, define what equality means for the particular data and compare those fields or use a suitable, carefully chosen library.

Copying objects is usually shallow

Object spread creates a new top-level object, but nested values are still references:

js
const original = {
  user: { name: "Ari" },
  tags: ["student"],
};

const copy = { ...original };

copy.user.name = "Bea";
console.log(original.user.name); // "Bea": nested object is shared
console.log(copy === original); // false: top-level objects differ

Array spread has the same shallow-copy behavior. Use targeted immutable updates for nested data, or choose a deliberate deep-copy strategy when the data shape requires one. JSON serialization is not a universal deep-clone solution: it loses values such as undefined, functions, symbols, dates' type identity, and cyclic references.

Equality, Coercion, and Truthiness

Strict equality and loose equality

=== compares without converting between different types. For primitives, it compares values (with the special NaN case); for objects, it compares identity. !== is its negated form.

js
5 === 5; // true
5 === "5"; // false
null === undefined; // false

The loose equality operator == applies coercion rules before comparing. It can produce surprising results:

js
0 == false; // true
"" == 0; // true
null == undefined; // true

Prefer === and !== for predictable checks. There is a narrow, intentional idiom where value == null checks for either null or undefined; use it only when accepting both values is clearly part of the API. Otherwise test them explicitly.

Strict equality has a few edge cases worth knowing:

js
NaN === NaN; // false
Object.is(NaN, NaN); // true
0 === -0; // true
Object.is(0, -0); // false

Most application code should not need Object.is, but it is useful to understand why NaN cannot be detected by comparing it to itself. Use Number.isNaN(value) to test for NaN.

Explicit conversion

When converting data from forms, URLs, JSON, or APIs, make the conversion explicit and validate the result:

js
const quantityText = "12";
const quantity = Number(quantityText);

if (!Number.isFinite(quantity) || quantity < 0) {
  throw new RangeError("Quantity must be a non-negative finite number.");
}

Number("") evaluates to 0, while Number("twelve") evaluates to NaN. A conversion is not the same as validation; check domain rules afterward. Avoid relying on + to convert implicitly because it also concatenates strings:

js
"12" + 3; // "123"
"12" - 3; // 9, because subtraction converts to numbers

Truthy and falsy values

Boolean contexts such as if, while, and the left side of && convert a value to a boolean. Falsy values are false, 0, -0, 0n, "", null, undefined, and NaN. All other values are truthy—including empty arrays and empty objects:

js
Boolean(""); // false
Boolean(0); // false
Boolean([]); // true
Boolean({}); // true

Truthiness is useful for simple presence checks, but it can accidentally conflate meaningful values. For example, a count of 0 is falsy, even though it may be a valid value.

|| versus ??

The logical OR operator returns the right side whenever the left side is falsy. Nullish coalescing (??) uses the right side only when the left side is null or undefined:

js
const retriesFromConfig = 0;

const retriesWithOr = retriesFromConfig || 3; // 3
const retriesWithNullish = retriesFromConfig ?? 3; // 0

Use ?? when only absent values should trigger a default. Use || when every falsy value should be treated as missing. Parentheses are required when mixing ?? with && or || in the same expression:

js
const label = (customLabel ?? fallbackLabel) || "Untitled";

Functions and Parameters

Functions package behavior and can accept inputs and return outputs. Prefer functions with clear responsibilities and explicit return values. Pure functions, which do not modify external state and return the same output for the same input, are easier to test and reuse.

Declarations, expressions, and arrows

A function declaration has a name and is available throughout its scope, including before its textual declaration due to declaration hoisting:

js
function formatName(name) {
  return name.trim();
}

A function expression is a function value assigned to a binding:

js
const formatName = function (name) {
  return name.trim();
};

An arrow function is a concise function expression:

js
const formatName = (name) => name.trim();
const add = (left, right) => left + right;

For an arrow function with a block body, write return explicitly:

js
const normalizeName = (name) => {
  const trimmed = name.trim();
  return trimmed.toLowerCase();
};

Arrow functions do not have their own this or arguments. This is useful for callbacks that should use the surrounding this, but arrows are not a drop-in replacement for every method or constructor. Use regular methods when an object's method needs this to refer to its receiver.

Default and rest parameters

A default parameter is used when an argument is omitted or explicitly undefined; it is not used for null, 0, or an empty string:

js
function greet(name = "learner") {
  return `Hello, ${name}!`;
}

greet(); // "Hello, learner!"
greet(undefined); // "Hello, learner!"
greet(null); // "Hello, null!"

Validate inputs when null or an invalid value is not acceptable. Defaults do not replace validation.

Rest parameters collect remaining arguments into an array:

js
function sum(...numbers) {
  return numbers.reduce((total, number) => total + number, 0);
}

sum(2, 3, 4); // 9

Prefer a named options object when a function has several optional settings:

js
function createGreeting({ name = "learner", excited = false } = {}) {
  const message = `Hello, ${name}`;
  return excited ? `${message}!` : message;
}

The default {} lets the caller omit the entire argument. Validate object properties if they come from an untrusted source.

Return values and side effects

A function without an explicit return produces undefined. The difference between returning a new value and changing an existing one is important:

js
function withDiscount(price, discount) {
  return price * (1 - discount);
}

This is predictable. A function that silently changes a shared object or writes to a global makes it harder to understand what happened. When side effects are necessary—such as saving data or updating the DOM—make them visible in the function's name, parameters, and calling context.

Scope and the Temporal Dead Zone

Scope determines where a binding can be accessed. JavaScript has global scope, function scope, and block scope.

Global and function scope

Bindings declared at the top level of a browser script or module are not always the same as properties on window; ES modules, for example, have module scope. Avoid creating application globals because they can collide with other scripts and obscure dependencies.

Function parameters and variables declared with var, let, or const inside a function are scoped to that function or more specific nested blocks:

js
function calculateTotal(items) {
  const subtotal = items.reduce((sum, item) => sum + item.price, 0);
  return subtotal;
}

// subtotal is not available here

Block scope: let and const

let and const are block-scoped. A block is formed by braces in constructs such as if, for, and standalone blocks:

js
if (true) {
  const message = "inside";
}

// message is not defined here

Use const by default when a binding will not be reassigned, and let when reassignment is needed. This communicates intent; it does not make referenced objects immutable.

var is function-scoped and has legacy hoisting behavior. New code should generally use let and const; understanding var remains useful when reading older code.

Temporal dead zone

let and const bindings are created when their scope is entered but cannot be accessed before the declaration is evaluated. This interval is the temporal dead zone (TDZ):

js
{
  // console.log(status); // ReferenceError: still in the TDZ
  const status = "ready";
  console.log(status);
}

This catches use-before-initialization errors instead of silently returning undefined. var behaves differently: its declaration is hoisted and initialized to undefined, which can hide ordering mistakes.

Closures capture lexical bindings

A function can use variables from the scope in which it was created, even after that outer function returns:

js
function makeCounter() {
  let count = 0;
  return () => {
    count += 1;
    return count;
  };
}

const nextCount = makeCounter();
nextCount(); // 1
nextCount(); // 2

The returned function closes over count. This is useful for encapsulating state, but closures also retain references to values. Long-lived event listeners or callbacks can keep data alive; remove listeners and release references when their lifecycle ends.

Objects: Access, Destructuring, and Safe Defaults

Objects associate property keys with values. Dot notation is convenient for known property names; bracket notation supports dynamic keys:

js
const settings = { theme: "dark" };
settings.theme;

const key = "theme";
settings[key];

Destructuring

Destructuring assigns properties to local bindings:

js
const course = { title: "JavaScript", level: "beginner" };
const { title, level } = course;

Aliases and defaults make it adaptable:

js
const { title: courseTitle, language = "en" } = course;

A destructuring default applies to undefined, not null. Destructuring does not copy nested objects; a destructured object property still refers to the original value.

Object spread and immutable updates

Object spread copies enumerable own properties into a new object. Later properties override earlier ones:

js
const defaults = { theme: "light", fontSize: 16 };
const userSettings = { theme: "dark" };
const merged = { ...defaults, ...userSettings };
// { theme: "dark", fontSize: 16 }

This is shallow. To update a nested property without modifying the original, copy each changed level:

js
const state = {
  profile: { name: "Ari", city: "Riga" },
};

const nextState = {
  ...state,
  profile: {
    ...state.profile,
    city: "Tallinn",
  },
};

Now nextState.profile is a new object, while state.profile is unchanged. For deeply nested data, consider whether the data model should be normalized or whether a dedicated immutable-update helper would make intent clearer.

Optional chaining

Optional chaining (?.) stops property access or a call when the value to its left is null or undefined, returning undefined instead of throwing:

js
const city = user.profile?.address?.city;
const firstItem = order.items?.[0];
const label = user.getLabel?.();

It does not make every invalid value safe. If profile exists but has the wrong shape, other operations can still fail. Validate external data at its boundary instead of using optional chaining to hide unexpected missing required fields.

Nullish coalescing for optional properties

Combine optional chaining with ?? to supply defaults only when the value is nullish:

js
const displayName = user.profile?.displayName ?? "Guest";
const pageSize = settings.pageSize ?? 20;

This preserves meaningful falsy values such as 0 and "". If an empty string should also count as missing, express that rule explicitly rather than choosing a default operator by habit.

Arrays: Transform Without Mutating

Arrays are ordered objects. Many array methods produce a new array, while others mutate the receiver. Choose methods based on the intended operation and check their behavior in documentation.

map: transform each value

map produces one output for every input:

js
const prices = [10, 20, 30];
const withTax = prices.map((price) => price * 1.1);
// prices is unchanged

Use map when the output has the same number of entries. Returning nothing from the callback produces undefined in that position; use filter if the goal is to remove entries.

filter: select values

filter returns a new array containing entries whose callback returns a truthy value:

js
const scores = [52, 91, 68, 37];
const passing = scores.filter((score) => score >= 60);

The new array does not clone its objects. If it contains object references, changing an object through the filtered array also affects the original object.

reduce: combine values

reduce carries an accumulator through the array and returns a single result:

js
const lineItems = [
  { name: "Notebook", price: 8 },
  { name: "Pen", price: 2 },
];

const total = lineItems.reduce(
  (sum, item) => sum + item.price,
  0,
);

Provide an initial value to make empty-array behavior predictable and communicate the accumulator's type. Use reduce when the combination is clearer than a specialized method; do not turn a simple filter-map sequence into a dense accumulator that is difficult to maintain.

flatMap: map and flatten one level

flatMap maps each entry and flattens the returned arrays by one level:

js
const sentences = ["red blue", "green"];
const words = sentences.flatMap((sentence) => sentence.split(" "));
// ["red", "blue", "green"]

It is useful when each source item produces zero, one, or several results:

js
const records = [
  { name: "Ada", active: true },
  { name: "Lin", active: false },
];

const activeNames = records.flatMap((record) =>
  record.active ? [record.name] : [],
);

This maps each record and drops inactive records by returning an empty array. For straightforward selection followed by transformation, filter(...).map(...) may be easier to read.

Sorting and mutation

The traditional sort() method mutates its array. Where supported, toSorted() returns a sorted copy:

js
const scores = [12, 4, 30];
const sorted = scores.toSorted((left, right) => left - right);
// scores is still [12, 4, 30]

If targeting environments without toSorted, make a shallow copy before calling sort:

js
const sorted = [...scores].sort((left, right) => left - right);

Without a comparator, sort() compares string representations, so numeric values can appear in an unexpected order. Also remember that copying an array does not clone object elements.

ES Modules

ES modules divide JavaScript into files with explicit exports and imports. A module has its own scope; top-level declarations do not automatically become global variables. Static imports are resolved before module execution, enabling tools to analyze dependencies.

Named exports

A module can export several named bindings:

js
// math.js
export function clamp(value, minimum, maximum) {
  return Math.min(Math.max(value, minimum), maximum);
}

export const TAU = Math.PI * 2;

Import only what is needed:

js
// chart.js
import { clamp, TAU } from "./math.js";

const angle = clamp(9, 0, TAU);

Named exports make refactoring and searching straightforward. Use explicit relative paths appropriate to the runtime and bundler.

Default exports

A module may have one default export:

js
// greeting.js
export default function greeting(name) {
  return `Hello, ${name}`;
}

The importer chooses the local name:

js
import formatGreeting from "./greeting.js";

Use named exports for modules with several related operations. Use a default export when the file has one clear primary value. Be consistent within a project.

Re-exports and dynamic imports

A module can re-export a public API:

js
export { clamp, TAU } from "./math.js";

Static imports are appropriate for code needed at startup. Dynamic import returns a promise and can defer a feature:

js
const { openEditor } = await import("./editor.js");
openEditor();

Bundlers can use dynamic imports as code-splitting boundaries. Handle loading failures where the user-facing feature needs recovery. A dynamic import is asynchronous and should not be treated like an immediately available synchronous binding.

Avoid circular dependencies when possible. Two modules that depend on each other can observe partially initialized exports or produce fragile initialization order. Extract shared types or functions into a third module if that clarifies ownership.

Common Mutation Bugs and Debugging

Updating shared state by accident

Consider an application state object shared across components:

js
const state = { filters: { query: "" } };
const next = { ...state };
next.filters.query = "css";

The outer copy is new, but next.filters and state.filters are the same object. The original state has changed. Copy the nested level too:

js
const next = {
  ...state,
  filters: {
    ...state.filters,
    query: "css",
  },
};

In UI frameworks, mutation can also prevent change detection or make previous state snapshots unusable. Follow the framework's state update model, and avoid mutating objects received as props or shared from a cache.

Mutating array operations

Methods such as push, pop, splice, sort, and reverse mutate an array. If another part of the program holds the same reference, it observes the change. Non-mutating alternatives include concat, filter, map, toSorted, and toReversed, or use spread and then mutate only the local copy.

Stale assumptions about a value

A value from a form or API may be a string, missing, null, or malformed. Validate at the boundary and convert once. Avoid writing deeply chained code that assumes shape without checking; use a schema or a small parsing function for data with multiple required fields.

Debugging shared references

When a value changes unexpectedly:

  1. Find every assignment and function call that receives the object or array.
  2. Check whether a method mutates its receiver.
  3. Compare references with === and inspect nested identity.
  4. Set a breakpoint before the mutation and inspect the call stack.
  5. Create the smallest reproducing example and write a test for the intended immutable result.

Object.freeze can help catch some accidental writes in development, but it is shallow and does not replace sound ownership or immutable update patterns.

Accessibility, Performance & Industry Best Practices

  • Use strict equality for predictable comparisons and explicit conversion for external data.
  • Distinguish “missing” from valid falsy values before choosing || or ??.
  • Prefer const; use let when rebinding is intentional and avoid new var declarations.
  • Keep functions focused, inputs explicit, and side effects visible.
  • Use pure transformations where practical; they are easier to test and reason about.
  • Treat object and array copies as shallow unless a deliberate deep-copy operation is used.
  • Prefer non-mutating array transformations when values are shared with UI state or caches.
  • Validate data from networks, storage, and forms before using it as trusted application state.
  • Use named ES module exports and avoid accidental globals and circular dependencies.
  • Choose clarity over clever chains; readable code is easier to review and maintain.
  • JavaScript structure supports accessibility when it preserves semantic DOM, keyboard behavior, and user feedback; test actual rendered interactions rather than assuming code-level correctness.
  • Optimize only after measuring. A clear map or filter is rarely a meaningful performance problem; algorithmic complexity and unnecessary repeated work matter more.

Hands-on Exercises

Exercise 1 — Build a safe course summary

Given the following data, write a function summarizeCourses(courses) that returns a new array containing only published courses. Each result must have slug, title, and a lessonCount. If lessons is missing, use an empty array. Do not mutate the input. Use optional chaining or nullish coalescing where helpful.

js
const courses = [
  { slug: "html", title: "HTML", published: true, lessons: ["structure", "forms"] },
  { slug: "css", title: "CSS", published: false, lessons: ["cascade"] },
  { slug: "js", title: "JavaScript", published: true },
];

Also describe what would go wrong if the implementation used courses.sort(...) directly or changed each original course to add lessonCount.

Exercise 2 — Fix shared-reference state and module boundaries

An application exports a default settings object from settings.js. A component imports it and updates a nested preference. Other components unexpectedly see the change:

js
// settings.js
export default {
  profile: { name: "Mina", preferences: { theme: "light" } },
};

// component.js
import settings from "./settings.js";
const nextSettings = { ...settings };
nextSettings.profile.preferences.theme = "dark";

Rewrite the example to use a named export, keep the original settings unchanged, and make the imports explicit. Explain which object levels must be copied and why a top-level spread alone fails. Add a small assertion proving that the old theme remains "light" while the new value is "dark".

Solutions

Solution 1

js
function summarizeCourses(courses) {
  return courses
    .filter((course) => course.published)
    .map((course) => ({
      slug: course.slug,
      title: course.title,
      lessonCount: course.lessons?.length ?? 0,
    }));
}

const summaries = summarizeCourses(courses);

filter creates a new array of published course references. map then creates new summary objects rather than attaching properties to the originals. Optional chaining handles an absent lessons property; ?? 0 supplies zero only for null or undefined. The input array and course objects remain unchanged.

Calling courses.sort(...) would reorder the original array because sort mutates its receiver. Adding lessonCount directly to each course would change shared source objects, which can surprise other consumers. If sorting is required, use courses.toSorted(compare) where supported, or [...courses].sort(compare).

For production data, also validate that published is a boolean and lessons, when present, is an array. Optional chaining prevents one access error but does not validate the full data shape.

Solution 2

js
// settings.js
export const settings = {
  profile: {
    name: "Mina",
    preferences: {
      theme: "light",
    },
  },
};
js
// component.js
import { settings } from "./settings.js";

const nextSettings = {
  ...settings,
  profile: {
    ...settings.profile,
    preferences: {
      ...settings.profile.preferences,
      theme: "dark",
    },
  },
};

console.assert(settings.profile.preferences.theme === "light");
console.assert(nextSettings.profile.preferences.theme === "dark");

The update copies the root settings object, its profile, and the nested preferences object where the changed value lives. A top-level spread only makes the root new; its profile property still points to the original profile, so changing preferences.theme through the copy mutates the original nested object. The named export makes the module's public binding explicit, and the named import mirrors it.