JavaScript Fundamentals: The Concepts Everything Else Is Built On

A practical tour of the core JavaScript ideas — types, scope, functions, objects, and asynchrony — that make every framework you learn afterwards feel obvious.

7 min read

Most developers meet JavaScript through a framework. They learn React, or Node, or whatever the team happens to use, and they pick up the language sideways along the way. It works — right up until something behaves strangely and there is no framework documentation to explain it, because the behaviour belongs to JavaScript itself.

This guide covers the fundamentals worth genuinely understanding. Not an exhaustive reference, but the concepts that keep resurfacing throughout a career of writing JavaScript.

Values and types

JavaScript has seven primitive types — string, number, boolean, null, undefined, symbol, and bigint — and one everything-else type: object. Arrays, functions, dates, and regular expressions are all objects underneath.

The distinction that matters day to day is how values are copied. Primitives are copied by value; objects are copied by reference.

let a = 10;
let b = a;
b = 20;
console.log(a); // 10 — untouched

const first = { name: "Ada" };
const second = first;
second.name = "Grace";
console.log(first.name); // "Grace" — same object

This single behaviour explains a large share of confusing bugs: a function "mutating your data" is usually just two names pointing at one object.

null versus undefined

undefined means a value was never assigned. null means someone deliberately assigned "nothing". A missing object property returns undefined; a database column with no value is often modelled as null. Treating them as interchangeable is fine until you need to tell "not set yet" apart from "explicitly empty".

Equality

Use === by default. The loose == operator applies type coercion before comparing, which produces results almost nobody wants:

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

The one genuinely useful case for == is value == null, which is true for both null and undefined and is a concise way to check "is this absent".

Truthiness

Eight values are falsy: false, 0, -0, 0n, "", null, undefined, and NaN. Everything else — including [], {}, and the string "false" — is truthy.

This trips people up in guard clauses. if (!count) rejects a legitimate count of zero. Reach for ?? (nullish coalescing) rather than || when zero and empty string are valid values:

const perPage = options.perPage ?? 25;  // only falls back on null/undefined
const perPageBuggy = options.perPage || 25;  // 0 becomes 25

Scope and declarations

Use const by default, let when you need to reassign, and var essentially never. const prevents reassignment of the binding, not mutation of the value — a const array can still be pushed to.

let and const are block-scoped: they exist only inside the nearest set of braces. var is function-scoped, which leaks in ways that surprise people:

function example() {
  if (true) {
    var leaked = "visible everywhere in this function";
    let contained = "visible only in this block";
  }
  console.log(leaked);    // works
  console.log(contained); // ReferenceError
}

Closures

A function keeps access to the variables of the scope it was defined in, even after that scope has finished executing. That is a closure, and it is the mechanism behind private state, callbacks, and most of the patterns you will encounter in real codebases.

function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count
  };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2

count is unreachable from outside — no property to accidentally overwrite, no way to set it to a nonsensical value. The closure is the encapsulation.

Functions

Functions are values. They can be assigned to variables, passed as arguments, and returned from other functions. This is what makes map, filter, and every callback-based API possible.

const orders = [
  { id: 1, total: 120, status: "paid" },
  { id: 2, total: 45,  status: "pending" },
  { id: 3, total: 310, status: "paid" }
];

const paidRevenue = orders
  .filter(order => order.status === "paid")
  .reduce((sum, order) => sum + order.total, 0);

console.log(paidRevenue); // 430

Prefer these over manual loops when you are transforming data. They state intent — "keep these, then total them" — rather than bookkeeping.

Arrow functions and this

Arrow functions are not just shorter syntax. A regular function gets its own this, determined by how it is called. An arrow function does not — it inherits this from the surrounding scope. That difference is why the classic callback bug exists:

const timer = {
  seconds: 0,
  startBroken() {
    setInterval(function () {
      this.seconds++; // `this` is not `timer` here
    }, 1000);
  },
  startWorking() {
    setInterval(() => {
      this.seconds++; // inherits `this` from startWorking
    }, 1000);
  }
};

Default, rest, and spread

function createUser(name, { role = "member", active = true } = {}) {
  return { name, role, active };
}

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

const base = { host: "localhost", port: 3000 };
const config = { ...base, port: 8080 }; // later keys win

Spread produces a shallow copy. Nested objects are still shared references — worth remembering before you assume you have cloned something.

Objects and arrays

Destructuring pulls values out by name or position, and pairs well with defaults:

const { name, email = "none" } = user;
const [first, second, ...rest] = items;

Optional chaining and nullish coalescing handle missing data without nested conditionals:

const city = user?.address?.city ?? "Unknown";
const result = api.getData?.(); // only calls if the method exists

Prototypes

JavaScript's inheritance is prototypal: every object has a link to another object, and property lookups walk that chain until they find a match or reach null. Calling "hello".toUpperCase() works because the lookup finds the method on String.prototype.

class syntax is a cleaner way to write this — the underlying mechanism is unchanged. Knowing that saves you when a debugger shows you a prototype chain instead of the class hierarchy you were expecting.

Asynchronous JavaScript

JavaScript runs on a single thread. It stays responsive by handing slow work — network requests, timers, file reads — to the environment and continuing on. When that work finishes, the callback is queued and runs once the current code completes.

This is why a long synchronous loop freezes a browser tab, and why setTimeout(fn, 0) does not run immediately — it runs after the current work is done.

Promises and async/await

A promise represents a value that is not available yet. It is pending, then either fulfilled or rejected. async/await is syntax over promises that lets asynchronous code read top to bottom:

async function loadDashboard(userId) {
  try {
    const user = await fetchUser(userId);
    const [orders, invoices] = await Promise.all([
      fetchOrders(user.id),
      fetchInvoices(user.id)
    ]);
    return { user, orders, invoices };
  } catch (error) {
    console.error("Dashboard load failed:", error);
    throw error;
  }
}

Note the Promise.all. Awaiting each request in sequence when they do not depend on each other is one of the most common performance mistakes in JavaScript code — two 300ms calls become 600ms for no reason. If the requests are independent, run them together.

Related helpers worth knowing: Promise.allSettled waits for everything and reports each outcome rather than rejecting on the first failure, and Promise.race settles as soon as the first one does — useful for timeouts.

Errors in async code

An await inside try/catch catches rejections normally. A promise without await or .catch() does not — it becomes an unhandled rejection, which in Node can terminate the process. Every promise needs an owner.

Modules

ES modules are the standard. Each file has its own scope, and you export what other files should use:

// pricing.js
export function applyDiscount(total, percent) {
  return total * (1 - percent / 100);
}

// checkout.js
import { applyDiscount } from "./pricing.js";

Prefer named exports over default exports. They rename explicitly, autocomplete reliably, and make it obvious what a module provides.

Where to go from here

These fundamentals do not expire. Frameworks change, build tools change, but closures, the event loop, and reference semantics behave the same way in a 2015 codebase and a 2026 one. Time spent here compounds — every library you pick up afterwards is written in terms of these ideas.

If you are working through a JavaScript or TypeScript codebase and want another pair of eyes on the architecture, or you are planning a project and want it built on solid foundations from the start, get in touch with Eight Mile. Custom software development, backend APIs, and technical consultancy are what we do.