← Back to Understanding Radicals

Building the Radical Class

A field-guide to designing a reusable JavaScript math class from scratch

class Radical  {  coeff · √radicand  }

1Why Build a Class?

Before writing a single line of code, ask: why does this deserve to be a class? A standalone function can handle a single operation. What makes a class worth the effort?

Here's the situation we faced in the Special Triangles app. We needed to:

  • Store a radical expression like 3√5
  • Simplify it — extract any perfect-square (or perfect-cube, etc.) factors
  • Display it in three formats: plain text for the canvas, HTML for a web page, and step-by-step for education
  • Approximate a decimal value
  • Multiply two radicals together

That's five behaviors, all centered on one concept: a single radical expression. A collection of unrelated functions would scatter that logic across your codebase. A class bundles the data and all its behaviors into one clean, self-contained unit.

💡 Key Concept — The OOP Rule of Thumb: If you find yourself writing multiple functions that all operate on the same data, that data and those functions probably belong together in a class. In OOP terms: nouns become classes, verbs become methods.
❌ Without a class
functions-only approach
// Data scattered as plain variables:
let coeff = 3, rootNdx = 2, radicand = 12;

// Every function must accept all three every time:
function simplifyRadical(c, n, r) { /* ... */ }
function radToString(c, n, r)     { /* ... */ }
function radToHTML(c, n, r)       { /* ... */ }
function multiplyRads(c1,n1,r1,c2,n2,r2) { /* ... */ }

// Calling them is verbose and error-prone:
const result = simplifyRadical(3, 2, 12);
const html   = radToHTML(result.c, result.n, result.r);
✅ With a class
class-based approach
// Data and behavior live together:
const rad = new Radical(3, 2, 12);

// Methods know their own data — no re-passing:
const simple = rad.simplify();
const text   = simple.toString();   // "2√3"
const html   = simple.toHTML();
const steps  = rad.simplifySteps();

// Chaining is natural and readable:
new Radical(2, 2, 6)
    .multiply(new Radical(1, 2, 3))
    .toString();   // "6√2"

2Step 1 — Define the Goal

The first step in designing any class is to write one sentence that defines what a single object of that class represents. This sentence drives every decision that follows.

"A Radical object represents a single radical expression of the form coeff · nradicand, where coeff, n (root index), and radicand are integers."

From this one sentence we can immediately answer three design questions:

QuestionAnswer
What data does the object store? Three values: coeff, rootNdx, radicand
What type should each field be? Integers — always, even if the user passes a float
What can it represent? Square roots, cube roots, nth roots; negative coefficients; negative radicands (odd root only)
🟢 Design Pattern — Name It. Describe It. Then Design It: Before writing any code, write a one-sentence description of what one object represents. If you can't fill in the blank clearly, keep thinking — the design isn't ready yet. Pin that sentence to your screen while you code; every method should serve it.

3Step 2 — The Constructor

The constructor accepts the three values and stores them. The critical design question: what if the caller passes a non-integer?

Radical.js — constructor
constructor(coeff = 1, rootNdx = 2, radicand = 1) {
    this.coeff    = Math.trunc(Number(coeff));
    this.rootNdx  = Math.trunc(Number(rootNdx));
    this.radicand = Math.trunc(Number(radicand));
}

There are two layers of defense applied to every parameter:

  1. Number(...) — converts strings like "3" or booleans to numeric type. An HTML <input> always yields a string; this handles that silently.
  2. Math.trunc(...) — drops the decimal part of any float, converting 3.93 (no rounding). Radical math requires integers; we enforce that here so every method downstream can assume it.
⚠ Why not just parseInt?
parseInt("3.7")3
parseInt(3.7)3
parseInt(true)NaN
Number(true)1, then Math.trunc1
💡 Key Concept — Normalize in the Constructor: Do all input cleaning in the constructor, not scattered across every method. Once the object exists, every method can trust its fields are valid. This is called invariant maintenance.

Default parameters (coeff = 1, rootNdx = 2, radicand = 1) mean new Radical() is valid and represents √1 = 1. Always provide sensible defaults — they make exploratory testing in the browser console much faster.

4Step 3 — Static Helper Methods

Some operations are pure utilities: they take inputs and return outputs without needing any object state. Those belong as static methods. We also prefix their names with _ to signal they're internal helpers, not part of the public API.

_primeFactors(n) — Trial Division

Radical.js — _primeFactors
static _primeFactors(n) {
    const factors  = {};
    let remaining  = Math.abs(Math.trunc(n));
    for (let p = 2; p * p <= remaining; p++) {
        while (remaining % p === 0) {
            factors[p] = (factors[p] || 0) + 1;
            remaining  = Math.trunc(remaining / p);
        }
    }
    if (remaining > 1) {
        factors[remaining] = (factors[remaining] || 0) + 1;
    }
    return factors;
}
  • We only test up to p * p <= remaining — if a factor larger than √n existed, its smaller pair would already have been found.
  • Result is a plain object { prime: exponent } — e.g. _primeFactors(72){ 2:3, 3:2 }.
  • We work on Math.abs(n) — the sign of the radicand is handled separately in simplify().

_toSuperscript(n) — Character Mapping

Radical.js — _toSuperscript
static _toSuperscript(n) {
    const MAP = {
        '0':'⁰', '1':'¹', '2':'²', '3':'³', '4':'⁴',
        '5':'⁵', '6':'⁶', '7':'⁷', '8':'⁸', '9':'⁹', '-':'⁻'
    };
    return String(n).split('').map(d => MAP[d] ?? d).join('');
}

Each digit character maps to its Unicode superscript equivalent. ?? d (nullish coalescing) passes through any character not in the map unchanged, making multi-digit indices like 12¹² work automatically.

💡 Key Concept — static for Pure Utilities: If a method doesn't reference this, make it static. The _ prefix is an agreed-upon JavaScript convention (not enforced by the language) that says: "this is an implementation detail — don't call it from outside the class."

5Step 4 — The Core Algorithm: simplify()

simplify() is the heart of the class. Two design decisions here deserve careful attention before examining the algorithm.

✨ Design Decision 1: Immutability

❌ Mutable (modifies the object)
simplify() {
    // Changes the object itself:
    this.coeff    = newCoeff;
    this.radicand = newRadicand;
    // The original values are now gone!
}
✅ Immutable (returns a new object)
simplify() {
    // Returns a NEW Radical, leaves this intact:
    return new Radical(newCoeff, n, newRadicand);
    // The original is still available!
}

With immutability you can do this safely:

const original = new Radical(1, 2, 12);  // √12
const simple   = original.simplify();    // 2√3
console.log(original.toString());        // "√12"  ← still valid!
console.log(simple.toString());          // "2√3"

✨ Design Decision 2: Guard Clauses

Check bad inputs at the very top and throw descriptive errors immediately. Don't let invalid data travel deep into the algorithm before failing:

Radical.js — simplify() guard clauses
simplify() {
    const n = this.rootNdx;
    if (n < 2) throw new RangeError("Root index must be 2 or greater.");

    if (this.coeff === 0 || this.radicand === 0) {
        return new Radical(0, n, 0);
    }

    const isNegRad = this.radicand < 0;
    if (isNegRad && n % 2 === 0) {
        throw new RangeError(
            "Even root of a negative radicand is not a real number.");
    }
    // ... rest of algorithm
}

✨ The Simplification Algorithm

Once you have the prime factorization of the radicand, you can extract any prime whose exponent is ≥ n by splitting into "outside" and "inside" parts:

Radical.js — simplify() algorithm core
    const absRad  = Math.abs(this.radicand);
    const factors = Radical._primeFactors(absRad);

    let outsideFactor  = 1;
    let insideRadicand = 1;

    for (const p in factors) {
        const prime   = parseInt(p, 10);
        const e       = factors[p];
        const outside = Math.floor(e / n);   // copies that come OUT
        const inside  = e % n;               // copies that STAY IN
        outsideFactor  *= Math.pow(prime, outside);
        insideRadicand *= Math.pow(prime, inside);
    }

    let newCoeff = this.coeff * outsideFactor;
    if (isNegRad && n % 2 !== 0) newCoeff = -newCoeff;

    return new Radical(newCoeff, n, insideRadicand);
💡 Worked Example — √72: 72 = 23 × 32. For n=2:
  • 23: outside = ⌊3/2⌋ = 1, inside = 3 mod 2 = 1 → one 2 comes out, one 2 stays.
  • 32: outside = ⌊2/2⌋ = 1, inside = 2 mod 2 = 0 → one 3 comes out, nothing stays.
  • outsideFactor = 2 × 3 = 6. insideRadicand = 2 × 1 = 2.
  • Result: 6√2  ✓
🟢 Design Pattern — Immutable + Guard Clauses: Most math classes benefit from this combination. Guard clauses catch bad input early and give helpful error messages. Immutability makes every result a new, independent value that can be stored or passed around without fear of unexpected side effects.

6Step 5 — The Remaining Core Methods

🔍 isSimple() — Using One Method to Test Another

Radical.js — isSimple()
isSimple() {
    try {
        const s = this.simplify();
        return s.coeff    === this.coeff &&
               s.radicand === this.radicand;
    } catch (_) {
        return false;
    }
}

Rather than re-implementing the simplification check, isSimple() calls simplify() and asks whether anything changed. The try/catch returns false for any invalid input (e.g., even root of a negative). This is the oracle pattern: delegate to an existing method that already knows the answer.

📊 approx() and value() — Computation vs. Formatting

Radical.js — approx() and value()
approx(decimals = 4) {
    const d = Math.max(0, Math.min(15, Math.trunc(Number(decimals))));
    // ...
    const rootValue = Math.pow(Math.abs(this.radicand), 1 / n);
    const result    = this.coeff * (isNeg ? -rootValue : rootValue);
    return result.toFixed(d);   // returns a STRING
}

value() {
    return parseFloat(this.approx(15));  // returns a NUMBER
}

Notice: approx() returns a string (formatted for display); value() returns a number (for arithmetic). JavaScript's toFixed() gives you a string even though it looks like a number — this distinction matters.

The clamping Math.max(0, Math.min(15, ...)) prevents decimals = -5 or decimals = 100 from crashing toFixed(), which only accepts 0–20. This is a real edge case worth guarding.

multiply() — Validating Cross-Instance Operations

Radical.js — multiply()
multiply(other) {
    if (!(other instanceof Radical)) {
        throw new TypeError("Argument must be a Radical instance.");
    }
    if (this.rootNdx !== other.rootNdx) {
        throw new RangeError(
            `Cannot multiply radicals with different root indices ` +
            `(${this.rootNdx} vs ${other.rootNdx}).`);
    }
    return new Radical(
        this.coeff    * other.coeff,
        this.rootNdx,
        this.radicand * other.radicand
    ).simplify();
}

Two guard clauses at the top: instanceof catches being called with the wrong type; the root-index check enforces the math rule that you can only directly multiply radicals with the same root index. The actual computation is three multiplications, then a delegation to .simplify() — existing methods do the heavy lifting.

💡 Key Concept — Validate Inputs at the Boundary: Validate anything that comes from outside the class at the very top of the method. Once past the guard clauses, the method can proceed without paranoia. Internally generated values (produced by methods you wrote) don't need re-validation.

7Step 6 — Display Methods

One of the most useful class design patterns is separating data from presentation. The Radical class stores three integers — and from that same data it can produce three entirely different outputs depending on where it needs to appear:

Method Returns Used for Example output
toString() String Canvas text, console, plain documents "3√5", "√2", "−√5"
toHTML() HTML string Web page innerHTML 3√5 (with overline bar)
simplifySteps() String[ ] Educational step-by-step display Array of narrative strings
Radical.js — toString()
toString() {
    const { coeff: c, rootNdx: n, radicand: r } = this;
    if (c === 0 || r === 0) return "0";

    const prefix = n === 2
        ? "√"
        : `${Radical._toSuperscript(n)}√`;

    if (r === 1)  return String(c);            // e.g. "6"   (no radical needed)
    if (c ===  1) return `${prefix}${r}`;      // e.g. "√5"
    if (c === -1) return `−${prefix}${r}`;     // e.g. "−√5"
    return `${c}${prefix}${r}`;                // e.g. "3√5"
}

Notice the destructuring assignment at the top: const { coeff: c, rootNdx: n, radicand: r } = this. This creates shorter local names without modifying the object. The four if branches handle each special case, so by the time we reach the final return, we know c ≠ ±1 and r ≠ 0, 1.

The "Show Your Work" Pattern

simplifySteps() doesn't compute anything new — it re-walks the same algorithm as simplify() but narrates each decision into an array of human-readable strings. This is extremely useful for educational tools: identical logic, different output format.

Radical.js — simplifySteps() excerpt
simplifySteps() {
    // ...
    steps.push(`Prime factorization:  ${absRad} = ${factorStr}`);

    for (const p in factors) {
        const outside = Math.floor(e / n);
        const inside  = e % n;
        if (outside > 0) {
            steps.push(
                `  ${p}^${e}: take out ${p}^${outside} = ${outVal} ` +
                `(since ${outVal}^${n} = ${Math.pow(outVal, n)}); ` +
                `leave ${p}^${inside} inside`);
        } else {
            steps.push(
                `  ${p}^${e}: exponent ${e} < ${n}, stays under the radical`);
        }
    }
    // ...
    steps.push(`Result: ${simple.toString()}`);
    return steps;
}
🟢 Design Pattern — One Object, Multiple Views: A well-designed class stores data once and provides multiple rendering methods. Any time you find yourself writing a separate function to "format" or "display" an object, that function probably belongs as a method on the class itself.

8The Full Source Code

Here is the complete Radical.js file. Read it top to bottom and you'll see all the patterns discussed above working together. Use the copy button to grab it for your own projects.

✔ Copied!
Radical.js — complete source

9🛠 Design Your Own Class

Every well-designed support class goes through roughly the same process. Use this checklist the next time you need to create one:

✅ Class Design Checklist

  1. Write the one-sentence description. "A ___ object represents a single ___." If you can't fill in that blank, keep thinking — the design isn't ready.
  2. List the fields. What data does one object need to store? What type should each field be?
  3. Design the constructor. What parameters does it accept? What input normalization is needed? What are sensible default values?
  4. Identify static helpers. Are there pure utility operations that don't need this? Prefix them with _ and mark them static.
  5. Plan the core methods. Should they mutate the object or return a new one? If your class represents an immutable value (like a number), prefer returning new objects.
  6. Add guard clauses. What invalid inputs could each method receive? Add checks at the top and throw descriptive errors. Use instanceof for methods that accept other class instances.
  7. Add display methods. At minimum: toString(). If it'll render in HTML: add toHTML(). If it's for an educational tool: add a showSteps() that narrates the algorithm.

📝 A Template to Start From

YourClass.js — starter template
/**
 * YourClass.js
 * Represents a single [describe the concept here].
 *
 * One object holds: [field1], [field2], ...
 */
class YourClass {

    constructor(field1 = defaultValue1, field2 = defaultValue2) {
        // Normalize: ensure fields are the correct type
        this.field1 = /* normalize */ field1;
        this.field2 = /* normalize */ field2;
    }

    // ── Static helpers ────────────────────────────────────────────────────

    static _helperMethod(input) {
        // Pure utility — no 'this' needed here
        return /* computed result */;
    }

    // ── Core methods ──────────────────────────────────────────────────────

    process() {
        // Guard clauses first
        if (/* bad input */) throw new RangeError("Descriptive message here.");

        // Core logic ...
        const result = /* ... */;

        // Return a NEW object (immutable) or modify this (mutable) — pick one
        return new YourClass(result);
    }

    // ── Display methods ───────────────────────────────────────────────────

    toString() {
        // Plain text — safe for canvas text(), console.log(), etc.
        return `/* format ${this.field1} and ${this.field2} */`;
    }

    toHTML() {
        // HTML markup — safe for element.innerHTML
        return `<span>${this.field1}</span>`;
    }
}
🟢 Final Tip — Test in the Browser Console: After writing your class, open DevTools (F12) and experiment immediately:

const x = new YourClass(3, 2);
console.log(x.toString());
console.log(x.process());

The console is the fastest REPL (Read-Eval-Print Loop) you have. Use it early and often as you build. Each small win builds confidence before you connect the class to the rest of your app.