A GPA calculator looks like a ten-minute project. You multiply, you add, you divide, and you're done.
Then you test it against a real transcript and the number is off by 0.01. Or 3.3 * 3 gives you 9.899999999999999. Or a student enters a course with zero credits and your page shows NaN. Or someone asks, "What do I need next semester to reach a 3.5?" and you realize you have no idea how to answer that.
This post walks through the whole thing properly: the math, a small and well-tested JavaScript implementation, the floating-point trap, input validation, and a target-GPA planner. By the end you'll have a module you can drop into any front end.
If you want a live reference to compare your output against while you build, here's a working calculator:
Let's get into it.
Table of contents
- The math in one paragraph
- Modeling the data
- The naive implementation (and why it's subtly wrong)
- Fixing floating-point error with scaled integers
- Cumulative GPA
- A target-GPA planner
- Validation and edge cases
- Writing tests
- Wiring it up to a simple UI
- Things real transcripts will throw at you
- Wrap-up
The math in one paragraph
GPA is a credit-weighted average. Each letter grade maps to grade points. You multiply grade points by the course's credit hours to get quality points, add up quality points across courses, and divide by total credit hours:
GPA = Σ (grade points × credits) ÷ Σ credits That's the whole formula. A 4-credit course matters more than a 1-credit course because it contributes more quality points and more credits to the denominator.
The standard unweighted 4.0 college scale with plus/minus grades looks like this:
| Grade | Points |
|---|---|
| A | 4.0 |
| A- | 3.7 |
| B+ | 3.3 |
| B | 3.0 |
| B- | 2.7 |
| C+ | 2.3 |
| C | 2.0 |
| C- | 1.7 |
| D | 1.0 |
| F | 0.0 |
Schools differ. Some cap A+ at 4.0, some give 4.3, some have no plus/minus grades at all. We'll make the scale a configurable object so you never hard-code it into your logic.
Modeling the data
Keep the data model boring. A course is just credits and a grade:
const course = { name: "Intermediate Accounting I", credits: 3, grade: "A-" }; A semester is an array of those. Cumulative history is two numbers: previous GPA and previous credits.
Resist the urge to store the computed GPA on each course. Derive everything from the raw inputs, and you'll never have stale values to debug.
The naive implementation (and why it's subtly wrong)
Here's the version most of us write first:
const GRADE_POINTS = { "A": 4.0, "A-": 3.7, "B+": 3.3, "B": 3.0, "B-": 2.7, "C+": 2.3, "C": 2.0, "C-": 1.7, "D": 1.0, "F": 0.0, }; function naiveGpa(courses) { let quality = 0; let credits = 0; for (const c of courses) { quality += GRADE_POINTS[c.grade] * c.credits; credits += c.credits; } return quality / credits; } It looks right, and for a lot of inputs it is. But there are three problems hiding in it.
Problem 1: floating-point error. JavaScript numbers are IEEE 754 doubles. Decimal values like 3.3 and 3.7 can't be represented exactly in binary, so arithmetic on them accumulates tiny errors. Try this in your console:
console.log(3.3 * 3); // 9.899999999999999 console.log(3.7 * 3); // 11.100000000000001 For a single GPA the error is far too small to show up in a two-decimal display. But it matters in two situations: when you compare results (gpa >= 3.5 when the true value is exactly 3.5), and when you round. A value that should be 3.495 can end up as 3.4949999999999997 and round the wrong way.
Problem 2: no validation. If credits is 0 or a string like "3" or undefined, you get NaN, Infinity, or silent string concatenation.
Problem 3: unknown grades. GRADE_POINTS["A+"] is undefined, and undefined * 3 is NaN, which then poisons the whole sum.
Let's fix all three.
Fixing floating-point error with scaled integers
The cleanest fix is to avoid decimals in the arithmetic. Store grade points as integers in tenths (37 instead of 3.7), and convert credits to tenths too so half-credit courses work. Multiply integers, add integers, and divide once at the very end.
// Grade points in tenths, so 3.7 is stored as 37. const GRADE_POINTS_TENTHS = Object.freeze({ "A": 40, "A-": 37, "B+": 33, "B": 30, "B-": 27, "C+": 23, "C": 20, "C-": 17, "D": 10, "F": 0, }); /** * Sum a semester into integer totals. * quality is scaled by 100 (grade tenths × credit tenths) * credits is scaled by 10 (credit tenths) */ export function semesterTotals(courses, scale = GRADE_POINTS_TENTHS) { let quality = 0; let credits = 0; for (const course of courses) { const gp = scale[course.grade]; if (gp === undefined) { throw new Error(`Unknown grade: "${course.grade}"`); } const creditTenths = Math.round(Number(course.credits) * 10); if (!Number.isFinite(creditTenths) || creditTenths <= 0) { throw new Error(`Invalid credits for "${course.name ?? course.grade}"`); } quality += gp * creditTenths; credits += creditTenths; } return { quality, credits }; } /** Convert scaled totals back to a GPA. Returns null if there are no credits. */ export function gpaFromTotals({ quality, credits }) { if (credits === 0) return null; // quality = 100 × true quality points, credits = 10 × true credits // quality / credits = 10 × GPA, so divide by 10 once more. return quality / credits / 10; } Why the scaling works: if a course is 3 credits with an A-, the integer math is 37 * 30 = 1110. The true quality points are 3.7 * 3 = 11.1, so 1110 is exactly 11.1 × 100. No rounding error, ever, until the single division at the end.
Add a small formatter for display:
export function formatGpa(gpa, digits = 2) { return gpa === null ? "—" : gpa.toFixed(digits); } A word of caution about toFixed: it formats the underlying binary value, so (1.005).toFixed(2) returns "1.00", not "1.01". For GPA this rarely bites because our final value comes from one integer division, but if exact "round half up" behavior matters to you, round on the scaled integers before dividing, or use a decimal library.
Cumulative GPA
Cumulative GPA is the same weighted average, just with your history included:
Cumulative = (prevGPA × prevCredits + semesterQualityPoints) ÷ (prevCredits + semesterCredits) You reconstruct previous quality points from the previous GPA and credits, then add the new semester. In code, using the same scaled units:
export function cumulativeGpa(prevGpa, prevCredits, semester) { const prevCreditTenths = Math.round(Number(prevCredits) * 10); const prevQualityScaled = Math.round(Number(prevGpa) * Number(prevCredits) * 100); return gpaFromTotals({ quality: prevQualityScaled + semester.quality, credits: prevCreditTenths + semester.credits, }); } Here's a worked example. A student has a 3.42 GPA over 60 credits. This semester they take 15 credits and earn 51.0 quality points.
- Previous quality points:
3.42 × 60 = 205.2 - New total:
205.2 + 51.0 = 256.2 - New total credits:
60 + 15 = 75 - Cumulative GPA:
256.2 ÷ 75 = 3.416, which displays as3.42
Notice how little it moved. With 60 credits already banked, one semester at roughly the same performance level barely shifts the average. The more credits you have, the more "inertia" the cumulative GPA has. That's exactly the kind of intuition worth surfacing in a UI.
A target-GPA planner
This is the feature that turns a calculator into a planning tool. Instead of "what's my GPA?", the question becomes "what do I need this semester to reach X?"
Rearranging the cumulative formula to solve for the semester GPA:
neededSemesterGpa = (target × (prevCredits + semCredits) − prevGPA × prevCredits) ÷ semCredits export function requiredSemesterGpa({ target, prevGpa, prevCredits, semesterCredits }) { const total = Number(prevCredits) + Number(semesterCredits); const needed = (Number(target) * total - Number(prevGpa) * Number(prevCredits)) / Number(semesterCredits); return { needed, reachable: needed <= 4.0, alreadyMet: needed <= 0, }; } Example: current 3.42 over 60 credits, taking 15 more, target 3.50.
requiredSemesterGpa({ target: 3.5, prevGpa: 3.42, prevCredits: 60, semesterCredits: 15, }); // needed ≈ 3.82, reachable: true Check the math: (3.5 × 75 − 3.42 × 60) ÷ 15 = (262.5 − 205.2) ÷ 15 = 3.82. So the student needs about a 3.82 this term, which is demanding but possible.
Now try a target of 3.8 with the same inputs: (3.8 × 75 − 205.2) ÷ 15 = 5.32. That's above 4.0, so reachable is false. The honest message to show is "not reachable in one semester; here's how many semesters it would take." A planner that quietly tells someone to earn a 5.32 is worse than no planner.
You can extend this to compute the minimum number of semesters by looping: assume a perfect 4.0 each term and count until the cumulative GPA crosses the target.
export function semestersToTarget({ target, prevGpa, prevCredits, creditsPerSemester, maxSemesters = 12 }) { let quality = Number(prevGpa) * Number(prevCredits); let credits = Number(prevCredits); for (let n = 1; n <= maxSemesters; n++) { quality += 4.0 * creditsPerSemester; credits += creditsPerSemester; if (quality / credits >= target) return n; } return null; // not reachable within maxSemesters } Validation and edge cases
Real users type real garbage. Decide up front what your calculator does in each case instead of discovering it in production.
| Input | What to do |
|---|---|
| Empty course row | Ignore it, don't throw |
Credits of 0 or negative | Reject with a clear message |
Credits as a string ("3") | Coerce with Number(), then validate |
Credits like 1.5 or 0.5 | Allow (this is why we scale credits by 10) |
| Grade not in the scale | Reject, don't produce NaN |
| No valid courses at all | Return null and show a dash, not 0.00 |
| Previous GPA above 4.0 | Warn: the user may be on a different scale |
| Negative previous credits | Reject |
The "return null, not 0" rule is worth underlining. A GPA of 0.00 is a real, meaningful (and alarming) result. Showing it when the user simply hasn't entered anything yet is misleading. Use null internally and a placeholder like — in the UI.
A small helper keeps the empty-row case tidy:
export function cleanRows(rows) { return rows.filter(r => r.grade && String(r.credits).trim() !== ""); } Writing tests
A calculator is a perfect candidate for tests, because every case has a known right answer you can check by hand. Node ships with a built-in test runner, so you don't need any dependencies:
// gpa.test.js import test from "node:test"; import assert from "node:assert/strict"; import { semesterTotals, gpaFromTotals, cumulativeGpa, formatGpa, } from "./gpa.js"; test("typical semester", () => { const courses = [ { credits: 3, grade: "A-" }, { credits: 3, grade: "B+" }, { credits: 3, grade: "B" }, { credits: 3, grade: "A" }, { credits: 1, grade: "A" }, ]; const totals = semesterTotals(courses); // 11.1 + 9.9 + 9.0 + 12.0 + 4.0 = 46.0 quality points over 13 credits assert.equal(formatGpa(gpaFromTotals(totals)), "3.54"); }); test("floating point does not leak into results", () => { const totals = semesterTotals([{ credits: 3, grade: "B+" }]); assert.equal(totals.quality, 990); // exactly 9.9 × 100 }); test("half-credit courses are supported", () => { const totals = semesterTotals([ { credits: 0.5, grade: "A" }, { credits: 3, grade: "B" }, ]); assert.equal(totals.credits, 35); }); test("cumulative GPA", () => { const semester = semesterTotals([ { credits: 3, grade: "A" }, { credits: 3, grade: "A" }, { credits: 3, grade: "B+" }, { credits: 3, grade: "B" }, { credits: 3, grade: "B-" }, ]); // 15 credits, 51.0 quality points assert.equal(formatGpa(cumulativeGpa(3.42, 60, semester)), "3.42"); }); test("unknown grade throws", () => { assert.throws(() => semesterTotals([{ credits: 3, grade: "A+" }]), /Unknown grade/); }); test("zero credits throws", () => { assert.throws(() => semesterTotals([{ credits: 0, grade: "A" }]), /Invalid credits/); }); test("no courses returns null, not zero", () => { assert.equal(gpaFromTotals(semesterTotals([])), null); }); Run it with:
node --test Two of these tests are worth copying into any numeric project. The "floating point does not leak" test pins down the exact integer result so a future refactor can't quietly reintroduce decimals. The "returns null, not zero" test locks in the empty-state behavior.
Wiring it up to a simple UI
Here's a minimal vanilla HTML and JS page. It's deliberately plain so you can see the structure without a framework in the way.
<form id="gpa-form" novalidate> <table> <thead> <tr><th>Course</th><th>Credits</th><th>Grade</th><th></th></tr> </thead> <tbody id="rows"></tbody> </table> <button type="button" id="add">Add course</button> <button type="submit">Calculate GPA</button> <p id="result" aria-live="polite">Semester GPA: —</p> </form> <script type="module"> import { semesterTotals, gpaFromTotals, formatGpa } from "./gpa.js"; const grades = ["A","A-","B+","B","B-","C+","C","C-","D","F"]; const rows = document.querySelector("#rows"); function addRow() { const tr = document.createElement("tr"); tr.innerHTML = ` <td><input name="name" aria-label="Course name"></td> <td><input name="credits" type="number" min="0.5" step="0.5" aria-label="Credits"></td> <td> <select name="grade" aria-label="Grade"> <option value="">Select</option> ${grades.map(g => `<option>${g}</option>`).join("")} </select> </td> <td><button type="button" class="remove" aria-label="Remove course">✕</button></td>`; rows.append(tr); } rows.addEventListener("click", e => { if (e.target.classList.contains("remove")) e.target.closest("tr").remove(); }); document.querySelector("#add").addEventListener("click", addRow); for (let i = 0; i < 4; i++) addRow(); document.querySelector("#gpa-form").addEventListener("submit", e => { e.preventDefault(); const data = [...rows.querySelectorAll("tr")] .map(tr => ({ name: tr.querySelector('[name="name"]').value, credits: ...

