JavaScript Functions Explained for Beginners

Thesis / short answer: Write a single JavaScript function to do one clear job, accept inputs as parameters (with sensible defaults), return a value, keep variables local, and let callers change behavior with a callback when needed. Below I evolve one function step‑by‑step so you can see the decisions, tradeoffs, failure modes, and a reproducible example.

Evolving example: one cartTotal function, step by step

We start with a minimal, runnable function and add parameters, defaults, and a callback. Keep this file separate from any module wrappers so you don’t accidentally create duplicate closures across files—see Vandutz’s notes on modules and imports for organizing code: JavaScript modules, imports, exports.

// v1 — minimal, single responsibility: sum prices and return a number
function cartTotal(prices) {
  let sum = 0;
  for (const p of prices) {
    sum += Number(p); // coerce in case a caller passes "10"
  }
  return sum;
}

// Example:
console.log(cartTotal([12, 20, 8])); // 40

Why this shape?

Decision: return a value instead of logging. Returning keeps the function reusable — the caller decides whether to display, store, or further calculate with the result. Coercion with Number() guards against string numbers but isn’t full validation (see Failure modes).

Parameters, returns, and clear inputs

Make every dependency explicit. Instead of reading a global taxRate, add it as a parameter so the function is predictable. Here we also add basic validation and a single, clear return value.

// v2 — explicit parameters and basic validation
function cartTotal(prices, taxRate) {
  if (!Array.isArray(prices)) throw new TypeError('prices must be an array');
  const subtotal = prices.reduce((sum, p) => sum + Number(p), 0);
  if (!Number.isFinite(taxRate)) throw new TypeError('taxRate must be a finite number');
  return subtotal * (1 + taxRate);
}

// Usage:
console.log(cartTotal([12, 20, 8], 0.08)); // 43.2

Decision: throw on the wrong argument types. For beginners, explicit errors make bugs easier to find. Alternative: return NaN or a sentinel; that can hide problems in larger systems.

Defaults and callbacks: make common cases easy and flexible

Default parameters reduce noisy checks for “no value provided.” When caller behavior varies (discount rules, rounding logic), accept a callback rather than adding conditionals. That keeps the core logic small and testable.

// v3 — default parameter + optional discount callback
function cartTotal(
  prices,
  taxRate = 0.08,
  discountFn = (subtotal) => subtotal // default: no discount
) {
  if (!Array.isArray(prices)) throw new TypeError('prices must be an array');
  const subtotal = prices.reduce((s, p) => s + Number(p), 0);
  const afterDiscount = discountFn(subtotal);

  if (!Number.isFinite(afterDiscount)) {
    throw new TypeError('discountFn must return a finite number');
  }

  const total = afterDiscount * (1 + taxRate);
  // Limitation: toFixed is for display; converting back to Number keeps it numeric but loses exact cents
  return Number(total.toFixed(2));
}

// Examples:
// No discount, default tax
console.log(cartTotal([12, 20, 8]));               // 43.20

// 10% off
console.log(cartTotal([12, 20, 8], 0.08, n => n * 0.9)); // 38.88

// $5 off over $30
console.log(cartTotal([12, 20, 8], 0.08, n => (n > 30 ? n - 5 : n))); // 39.96

Decision: validate the discountFn result. A common bug is a callback that returns undefined or a string. This check surfaces that immediately rather than producing NaN or a strange string.

Return-value contracts

When a function returns a value, that value is a contract between the function and its callers. Be explicit about three things: the type, the invariants, and the error model. For cartTotal, the contract we enforce is “returns a finite Number representing dollars, rounded to two decimals; throws on invalid inputs.” Document that in comments, JSDoc, or TypeScript signatures so callers and future maintainers know exactly what to expect.

Contracts determine compatibility. If you later change cartTotal to sometimes return null or an object (for additional metadata), you should introduce a new function or version the API. Silent changes to return shapes are a frequent source of bugs. Prefer throwing for programmer errors and returning predictable sentinel values only when callers are explicitly expected to handle them.

Pure versus stateful functions

A pure function (same inputs → same outputs, no side effects) is easier to test and reason about. cartTotal as written is pure: given the same prices, taxRate, and discountFn (assumed pure), it returns the same number and performs no I/O. That makes caching, memoization, and unit testing straightforward.

Stateful functions—those that read or write external state, mutate inputs, or depend on time—are sometimes necessary (e.g., generate an order id, persist to storage). When you introduce statefulness, minimize its scope and clearly separate the pure calculation from side effects. A common pattern: keep calculateSubtotal and cartTotal pure, and have a thin side-effecting wrapper that reads configuration, logs usage, or updates the UI. This separation preserves testability while allowing real-world interactions.

Callbacks in a realistic event flow

In a browser app the cartTotal call often lives inside an event handler (click, submit) or an async flow (network request). Consider this realistic flow:

  1. User clicks “Checkout”.
  2. Event handler gathers cart items from UI state.
  3. Handler calls cartTotal to compute charges and then displays a preview.
  4. Handler submits the order to a server.

Two practical callback patterns arise:

  • Synchronous callbacks (current discountFn): good when discount logic is local and deterministic. Keep the API synchronous so UI code can update immediately.
  • Asynchronous discounts (server-side coupons, remote pricing): either make discountFn return a Promise and provide an async cartTotalAsync variant, or resolve the discount before calling a synchronous cartTotal. Mixing synchronous expectations with async callbacks leads to hard-to-debug race conditions.

Example pattern (recommended): fetch discount data, then call a pure cartTotal

// pseudo flow — keep calculation synchronous
async function onCheckoutClick(cartItems) {
  const discount = await fetchDiscountForUser(); // network call
  const total = cartTotal(cartItems, 0.08, n => n - discount);
  showPreview(total);
  await submitOrder({ total });
}

Keeping cartTotal synchronous when possible simplifies UI logic and avoids unintentional re-entrancy in event handlers. If you must support async discount logic inside the API, make that explicit with an async function name or a documented Promise return.

Refactoring decision: when and how to split the function (practical case)

We already suggested extracting calculateSubtotal. Here’s a concrete refactor decision: you notice cartTotal being called in two places—one that needs only subtotal and one that needs formatted totals plus audit metadata. Rather than adding branching flags to cartTotal, extract three pieces:

  • calculateSubtotal(prices) — pure, returns cents or dollars as a Number
  • applyDiscount(subtotal, discountSpec) — encapsulates discount policy (can be swapped or tested independently)
  • formatTotal(total, options) — presentation-only formatting (currency symbol, decimals)

Tradeoffs: this increases the number of exported functions but reduces branching and makes each piece small and testable. It also avoids duplicating closure state because everything is pure and stateless. If performance becomes a concern, memoize calculateSubtotal keyed by a stable cart signature; keep memoization local to a single module to avoid duplicated caches across bundles (see Vandutz module guidance).

Scope and hidden dependencies

Keep temporary variables local (const/let). Reading outer-scope values creates hidden dependencies that make testing and reuse harder. If you must use shared configuration, prefer a single options object that callers import once from a module; avoid re-declaring the same closure or configuration object in multiple files (this can create difficult-to-find duplication).

If you need a global constant—e.g., application default tax—export it from one module and import it where needed (see: Modules, imports, and bundlers). That prevents multiple copies of the same closure/data and keeps behavior consistent.

Refactoring decisions: when to split the function

Keep this single function focused. Split when:

  • The function does more than one job (calculation vs formatting vs DOM updates).
  • There are 4+ parameters—consider an options object or helper functions.
  • You need to reuse parts separately (subtotal calculation used elsewhere).

Example refactor idea (not duplicating closures): extract calculateSubtotal so tests can call it directly.

// helper without wrapping it in another closure
function calculateSubtotal(prices) {
  if (!Array.isArray(prices)) throw new TypeError('prices must be an array');
  return prices.reduce((s, p) => s + Number(p), 0);
}

function cartTotal(prices, taxRate = 0.08, discountFn = n => n) {
  const subtotal = calculateSubtotal(prices);
  const afterDiscount = discountFn(subtotal);
  if (!Number.isFinite(afterDiscount)) throw new TypeError('discountFn must return a number');
  return Number((afterDiscount * (1 + taxRate)).toFixed(2));
}

Decision: keep helpers as plain exported functions in one module rather than embedding multiple IIFEs or duplicate module wrappers. That avoids duplicate closures and makes tree-shaking and testing easier.

Failure modes and practical limitations

  • Type coercion: Number(“10”) works, but Number(“ten”) gives NaN. Consider stronger validation if input is untrusted.
  • Order of parameters: long parameter lists are error-prone. An options object (with defaults) can be clearer for >2 optional values.
  • Floating-point rounding: toFixed returns a string; converting back to Number can hide that cents are not exact. For money, consider integer cents or a proper monetary library for production systems.
  • Callback safety: always document expectations for discountFn (input and return types). Defensive checks prevent silent failures.
  • Hidden state and duplication: if you import the same default config from multiple files but accidentally re-create it in each file, you now have duplicated state/closures. Centralize shared state in one module to avoid this.

Code-reading checklist

  1. Is the function name a single clear job (verb + noun)? e.g., cartTotal, calculateSubtotal.
  2. Are all dependencies passed in as parameters or imported from a single module? (No hidden globals.)
  3. Does the function return a value rather than performing the final side effect?
  4. Are temporary variables declared with let/const and limited to the function scope?
  5. Are default parameters documented and implemented with the language defaults?
  6. If a callback is accepted, is its input and return type validated or documented?
  7. Does every execution path return a value or throw an error—no silent undefined returns?
  8. Are helper functions placed in one module to avoid creating duplicate closures? (See Vandutz module guidance: modules & imports.)

Resources and next steps

Practice exercise: Implement cartTotal using integer cents (convert dollars to cents at the start, do integer math, then format back to dollars) to avoid floating-point rounding issues. Keep calculateSubtotal in the same module so you do not create multiple closures with overlapping state.

Alex Carter is the editorial name responsible for Vandutz Academy’s technical direction; the site does not claim a specific degree, employer, certification, or public profile.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top