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.
// 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);
// 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
Radicalobject represents a single radical expression of the form coeff · n√radicand, where coeff, n (root index), and radicand are integers."
From this one sentence we can immediately answer three design questions:
| Question | Answer |
|---|---|
| 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) |
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?
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:
Number(...)— converts strings like"3"or booleans to numeric type. An HTML<input>always yields a string; this handles that silently.Math.trunc(...)— drops the decimal part of any float, converting3.9→3(no rounding). Radical math requires integers; we enforce that here so every method downstream can assume it.
parseInt?parseInt("3.7") → 3 ✓parseInt(3.7) → 3 ✓parseInt(true) → NaN ✗Number(true) → 1, then Math.trunc → 1 ✓
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
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 insimplify().
⚡ _toSuperscript(n) — Character Mapping
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.
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
simplify() {
// Changes the object itself:
this.coeff = newCoeff;
this.radicand = newRadicand;
// The original values are now gone!
}
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:
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:
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);
- 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 ✓
6Step 5 — The Remaining Core Methods
🔍 isSimple() — Using One Method to Test Another
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
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
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.
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 |
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.
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;
}
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.
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
-
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. - List the fields. What data does one object need to store? What type should each field be?
- Design the constructor. What parameters does it accept? What input normalization is needed? What are sensible default values?
-
Identify static helpers.
Are there pure utility operations that don't need
this? Prefix them with_and mark themstatic. - 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.
-
Add guard clauses.
What invalid inputs could each method receive?
Add checks at the top and throw descriptive errors.
Use
instanceoffor methods that accept other class instances. -
Add display methods.
At minimum:
toString(). If it'll render in HTML: addtoHTML(). If it's for an educational tool: add ashowSteps()that narrates the algorithm.
📝 A Template to Start From
/**
* 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>`;
}
}
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.