Episode 1 — Fundamentals / 1.20 — Functions

1.20.f — Practice Problems

In one sentence: These hands-on problems let you apply everything from function declarations and parameters to closures and higher-order functions — each problem includes a statement, hints, a full solution, and an explanation of why it works.

Navigation: ← 1.20.e — Writing Reusable Logic · 1.20.g — Recursion →


How to use these problems

  1. Read the problem statement and try to solve it yourself first.
  2. Use hints only if stuck after 5+ minutes.
  3. Compare your solution to the provided one — there are multiple valid approaches.
  4. Read the explanation to understand the key concepts.
  5. Extend each problem with your own variations.

Problem 1: Grade Calculator

Task: Write a function getGrade(score) that takes a numeric score (0-100) and returns a letter grade.

ScoreGrade
90-100A
80-89B
70-79C
60-69D
0-59F

The function should also handle invalid input (negative numbers, scores > 100, non-numbers).

Hints:

  • Use early returns for validation.
  • Use if/else if or a series of comparisons.

Solution:

function getGrade(score) {
  // Validation
  if (typeof score !== "number" || isNaN(score)) return "Invalid: not a number";
  if (score < 0 || score > 100) return "Invalid: score must be 0-100";

  // Grade boundaries
  if (score >= 90) return "A";
  if (score >= 80) return "B";
  if (score >= 70) return "C";
  if (score >= 60) return "D";
  return "F";
}

// Tests
console.log(getGrade(95));    // "A"
console.log(getGrade(83));    // "B"
console.log(getGrade(72));    // "C"
console.log(getGrade(65));    // "D"
console.log(getGrade(45));    // "F"
console.log(getGrade(-5));    // "Invalid: score must be 0-100"
console.log(getGrade(105));   // "Invalid: score must be 0-100"
console.log(getGrade("abc")); // "Invalid: not a number"

Explanation: Early return handles edge cases first (guard clauses). Since each if returns, no else is needed — if a condition matches, the function exits immediately. The grade boundaries work because they are checked from highest to lowest.


Problem 2: Even/Odd Checker

Task: Write two functions:

  1. isEven(num) — returns true if the number is even.
  2. describeNumber(num) — returns a string like "4 is even" or "7 is odd", using isEven internally.

Hints:

  • The modulo operator % gives the remainder of division.
  • Compose the two functions.

Solution:

function isEven(num) {
  if (typeof num !== "number" || !Number.isInteger(num)) {
    throw new TypeError("Expected an integer");
  }
  return num % 2 === 0;
}

function describeNumber(num) {
  const parity = isEven(num) ? "even" : "odd";
  return `${num} is ${parity}`;
}

// Tests
console.log(isEven(4));          // true
console.log(isEven(7));          // false
console.log(isEven(0));          // true
console.log(isEven(-3));         // false
console.log(describeNumber(4));  // "4 is even"
console.log(describeNumber(7));  // "7 is odd"
console.log(describeNumber(-2)); // "-2 is even"

Explanation: isEven is a pure predicate function (returns boolean, no side effects). describeNumber composes it into a user-friendly string. Separating them means isEven is reusable anywhere you need a boolean check.


Problem 3: Temperature Converter

Task: Write two functions:

  1. celsiusToFahrenheit(celsius) — converts Celsius to Fahrenheit.
  2. fahrenheitToCelsius(fahrenheit) — converts Fahrenheit to Celsius.

Formula: F = C * 9/5 + 32 and C = (F - 32) * 5/9

Then write a general convertTemperature(value, from, to) that uses both.

Hints:

  • Round to 2 decimal places with .toFixed(2) or Math.round.
  • The general function delegates to the specific ones.

Solution:

function celsiusToFahrenheit(celsius) {
  return celsius * (9 / 5) + 32;
}

function fahrenheitToCelsius(fahrenheit) {
  return (fahrenheit - 32) * (5 / 9);
}

function convertTemperature(value, from, to) {
  if (typeof value !== "number") throw new TypeError("Value must be a number");

  const fromUnit = from.toUpperCase();
  const toUnit = to.toUpperCase();

  if (fromUnit === toUnit) return value;

  if (fromUnit === "C" && toUnit === "F") {
    return Math.round(celsiusToFahrenheit(value) * 100) / 100;
  }
  if (fromUnit === "F" && toUnit === "C") {
    return Math.round(fahrenheitToCelsius(value) * 100) / 100;
  }

  throw new Error(`Unknown conversion: ${from} to ${to}`);
}

// Tests
console.log(celsiusToFahrenheit(0));    // 32
console.log(celsiusToFahrenheit(100));  // 212
console.log(fahrenheitToCelsius(32));   // 0
console.log(fahrenheitToCelsius(212));  // 100
console.log(convertTemperature(37, "C", "F")); // 98.6
console.log(convertTemperature(98.6, "F", "C")); // 37
console.log(convertTemperature(50, "C", "C")); // 50

Explanation: The specific functions (celsiusToFahrenheit, fahrenheitToCelsius) are pure and single-responsibility. The general function convertTemperature acts as a router, delegating to the right converter. Math.round(x * 100) / 100 avoids floating-point display issues.


Problem 4: Simple Calculator

Task: Write a function calculate(a, operator, b) that supports +, -, *, /, and %. Return the result or throw an error for division by zero or unknown operators.

Hints:

  • Use a switch statement or an object lookup.
  • Validate the operator.

Solution:

function calculate(a, operator, b) {
  if (typeof a !== "number" || typeof b !== "number") {
    throw new TypeError("Both operands must be numbers");
  }

  switch (operator) {
    case "+": return a + b;
    case "-": return a - b;
    case "*": return a * b;
    case "/":
      if (b === 0) throw new Error("Cannot divide by zero");
      return a / b;
    case "%":
      if (b === 0) throw new Error("Cannot modulo by zero");
      return a % b;
    default:
      throw new Error(`Unknown operator: ${operator}`);
  }
}

// Tests
console.log(calculate(10, "+", 5));   // 15
console.log(calculate(10, "-", 5));   // 5
console.log(calculate(10, "*", 5));   // 50
console.log(calculate(10, "/", 3));   // 3.3333...
console.log(calculate(10, "%", 3));   // 1
// console.log(calculate(10, "/", 0)); // Error: Cannot divide by zero
// console.log(calculate(10, "^", 2)); // Error: Unknown operator: ^

Alternative approach — object lookup (no switch):

function calculate(a, operator, b) {
  const operations = {
    "+": (x, y) => x + y,
    "-": (x, y) => x - y,
    "*": (x, y) => x * y,
    "/": (x, y) => {
      if (y === 0) throw new Error("Cannot divide by zero");
      return x / y;
    },
    "%": (x, y) => {
      if (y === 0) throw new Error("Cannot modulo by zero");
      return x % y;
    },
  };

  const fn = operations[operator];
  if (!fn) throw new Error(`Unknown operator: ${operator}`);
  return fn(a, b);
}

Explanation: The object-lookup version uses functions as values (first-class citizens). Each operation is a function stored in an object, retrieved by key. This pattern is more extensible — adding a new operator means adding one line.


Problem 5: String Reverser

Task: Write a function reverseString(str) that returns the reversed version of a string. Do NOT use the built-in .reverse() array method — build it manually with a loop.

Hints:

  • You can iterate from the end of the string backward.
  • Strings are iterable, so for...of works too.

Solution:

function reverseString(str) {
  if (typeof str !== "string") throw new TypeError("Expected a string");

  let reversed = "";
  for (let i = str.length - 1; i >= 0; i--) {
    reversed += str[i];
  }
  return reversed;
}

// Tests
console.log(reverseString("hello"));     // "olleh"
console.log(reverseString("JavaScript")); // "tpircSavaJ"
console.log(reverseString(""));           // ""
console.log(reverseString("a"));          // "a"
console.log(reverseString("racecar"));    // "racecar" (palindrome!)

Bonus — with built-in methods (for comparison):

function reverseStringBuiltin(str) {
  return str.split("").reverse().join("");
}
// Note: this approach has issues with Unicode surrogate pairs (emojis, etc.)
// A more robust version: [...str].reverse().join("")

Explanation: The loop approach builds a new string character by character from the end. This demonstrates basic string iteration and builds understanding before using shorthand methods.


Problem 6: Factorial

Task: Write a function factorial(n) that returns n! (n factorial). Use a loop (not recursion — we cover that in 1.20.g).

Recall: 5! = 5 * 4 * 3 * 2 * 1 = 120 and 0! = 1.

Hints:

  • Start with a result of 1 and multiply in a loop.
  • Handle edge cases: 0, negative numbers.

Solution:

function factorial(n) {
  if (typeof n !== "number" || !Number.isInteger(n)) {
    throw new TypeError("Expected a non-negative integer");
  }
  if (n < 0) throw new RangeError("Factorial is not defined for negative numbers");
  if (n === 0 || n === 1) return 1;

  let result = 1;
  for (let i = 2; i <= n; i++) {
    result *= i;
  }
  return result;
}

// Tests
console.log(factorial(0));  // 1
console.log(factorial(1));  // 1
console.log(factorial(5));  // 120
console.log(factorial(10)); // 3628800
console.log(factorial(20)); // 2432902008176640000

Explanation: Starting result at 1 and multiplying from 2 up to n avoids an unnecessary * 1 step. The guard clauses handle bad input early. Note that JavaScript numbers lose precision for very large factorials (beyond about n = 170).


Problem 7: Power Function

Task: Write a function power(base, exponent) that returns base raised to the exponent power. Handle non-negative integer exponents only (no negative exponents needed). Do NOT use Math.pow or **.

Hints:

  • Any number to the power of 0 is 1.
  • Multiply base by itself exponent times.

Solution:

function power(base, exponent) {
  if (typeof base !== "number" || typeof exponent !== "number") {
    throw new TypeError("Both arguments must be numbers");
  }
  if (!Number.isInteger(exponent) || exponent < 0) {
    throw new RangeError("Exponent must be a non-negative integer");
  }

  let result = 1;
  for (let i = 0; i < exponent; i++) {
    result *= base;
  }
  return result;
}

// Tests
console.log(power(2, 0));   // 1
console.log(power(2, 1));   // 2
console.log(power(2, 10));  // 1024
console.log(power(3, 4));   // 81
console.log(power(5, 3));   // 125
console.log(power(10, 0));  // 1

Explanation: The loop multiplies result by base for each unit of the exponent. power(2, 10) runs the loop 10 times: 1 * 2 * 2 * 2 * ... * 2 = 1024. The base case exponent === 0 returns 1 (any number to the zero power).


Problem 8: Array Statistics

Task: Write four utility functions that each take an array of numbers:

  1. getSum(arr) — returns the sum
  2. getAverage(arr) — returns the average
  3. getMin(arr) — returns the minimum value
  4. getMax(arr) — returns the maximum value

Then write getStats(arr) that returns an object with all four values.

Hints:

  • getAverage can reuse getSum.
  • Handle empty arrays gracefully.

Solution:

function getSum(arr) {
  if (!Array.isArray(arr) || arr.length === 0) {
    throw new Error("Expected a non-empty array of numbers");
  }
  let total = 0;
  for (const num of arr) {
    total += num;
  }
  return total;
}

function getAverage(arr) {
  return getSum(arr) / arr.length; // Reuses getSum — DRY!
}

function getMin(arr) {
  if (!Array.isArray(arr) || arr.length === 0) {
    throw new Error("Expected a non-empty array of numbers");
  }
  let min = arr[0];
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] < min) min = arr[i];
  }
  return min;
}

function getMax(arr) {
  if (!Array.isArray(arr) || arr.length === 0) {
    throw new Error("Expected a non-empty array of numbers");
  }
  let max = arr[0];
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] > max) max = arr[i];
  }
  return max;
}

function getStats(arr) {
  return {
    sum: getSum(arr),
    average: getAverage(arr),
    min: getMin(arr),
    max: getMax(arr),
    count: arr.length,
  };
}

// Tests
const scores = [88, 92, 76, 95, 63, 81];

console.log(getSum(scores));     // 495
console.log(getAverage(scores)); // 82.5
console.log(getMin(scores));     // 63
console.log(getMax(scores));     // 95
console.log(getStats(scores));
// { sum: 495, average: 82.5, min: 63, max: 95, count: 6 }

Explanation: Each utility function has a single responsibility. getAverage reuses getSum (DRY principle). getStats composes all four into a single object. The validation in each function ensures they fail gracefully on bad input. This is exactly how utility libraries are built in real projects.


Bonus challenges

If you finished the above, try these extensions:

  1. Extend the calculator (Problem 4) to support exponentiation (**) using your power function from Problem 7.
  2. Create a isPalindrome(str) function using reverseString from Problem 5.
  3. Make getStats accept a second parameter — a custom comparator function for min/max (higher-order function pattern).
  4. Create a convertAll function that takes an array of temperatures and a direction, returning all converted values using convertTemperature from Problem 3.
// Example for bonus 2
function isPalindrome(str) {
  const cleaned = str.toLowerCase().replace(/[^a-z0-9]/g, "");
  return cleaned === reverseString(cleaned);
}

console.log(isPalindrome("racecar"));          // true
console.log(isPalindrome("A man a plan Panama")); // true (ignoring spaces/case)
console.log(isPalindrome("hello"));            // false

Key takeaways

  1. Start with validation — guard clauses catch bad input early and keep the happy path clean.
  2. Small, focused functions are easier to test and reuse than monolithic ones.
  3. Compose functions — getAverage reuses getSum, getStats composes all utilities.
  4. Object lookup for operations (Problem 4 alternative) leverages functions as first-class citizens.
  5. Test edge cases — empty arrays, zero, negative numbers, special strings.

Explain-It Challenge

Explain without notes:

  1. Why is the object-lookup version of the calculator more extensible than the switch version?
  2. How does getAverage demonstrate the DRY principle?
  3. What edge case does factorial(0) test, and why does 0! equal 1?

Navigation: ← 1.20.e — Writing Reusable Logic · 1.20.g — Recursion →