A 30-year monthly loan has 360 payments. Generating every row is useful when a UI needs an amortization table, but it is unnecessary when the product only needs one answer:
What is the remaining balance after payment 60?
For a fixed-rate, fixed-payment loan, the answer has a closed form. You can calculate it in constant time and constant extra space—no 360-element array, no loop through every previous payment.
This post derives that formula, implements it in TypeScript, and tests it against an iterative reference implementation. The goal is not to replace a full schedule everywhere; it is to choose the smallest correct computation for the feature you are building.
The model assumes a fixed annual rate, monthly payments, an annuity (level-payment) repayment method, no fees, no prepayments, and a first payment one month after disbursement. It is a programming example, not a loan quote or financial advice.
Define the inputs precisely
We need four values:
type FixedRateLoan = {
principal: number; // P: amount borrowed
annualRate: number; // for example, 0.035 for 3.5%
months: number; // total scheduled monthly payments
};
Let:
-
Pbe the original principal; -
rbe the monthly rate,annualRate / 12; -
nbe the total number of payments; -
Abe the fixed monthly payment; and -
kbe the number of payments already made.
The balance we want is the balance immediately after payment k. That timing matters. A balance before the 60th payment includes one more month's interest than a balance after it.
First calculate the fixed payment
For a positive monthly rate, the annuity payment is:
A = P × r × (1 + r)^n / ((1 + r)^n - 1)
The zero-rate case must be handled separately. If r is zero, the payment is simply P / n; substituting zero into the expression above creates 0 / 0.
export function annuityPayment(loan: FixedRateLoan): number {
const { principal, annualRate, months } = loan;
if (principal < 0 || annualRate < 0 || months < 1) {
throw new Error("principal and annualRate must be non-negative; months must be positive");
}
const r = annualRate / 12;
if (r === 0) return principal / months;
const factor = Math.pow(1 + r, months);
return (principal * r * factor) / (factor - 1);
}
This payment formula is not yet the remaining balance. It only tells us the fixed amount paid each month. The balance also depends on how interest accumulates and how each payment reduces it.
Derive the balance formula
At the start, the balance is P. After one period of interest and one payment:
B₁ = P × (1 + r) - A
After two periods:
B₂ = (P × (1 + r) - A) × (1 + r) - A
Expanding that recurrence produces a geometric series. After k payments:
Bₖ = P × (1 + r)^k - A × (((1 + r)^k - 1) / r)
The first term grows the original balance through k interest periods. The second term is the future-value-at-month-k effect of the k payments already made.
An equivalent, and sometimes more intuitive, expression discounts the remaining payments back to month k:
Bₖ = A × (1 - (1 + r)^-(n - k)) / r
Both are valid for r > 0. In code, the first form makes the relationship to the recurrence explicit; the second is a useful independent check in a test.
Implement the O(1) balance calculation
The function below keeps the inputs immutable, clamps the paid-payment count to the loan term, and handles the zero-rate case.
const clamp = (value: number, min: number, max: number): number =>
Math.min(max, Math.max(min, value));
export function remainingAnnuityBalance(
loan: FixedRateLoan,
paidPayments: number,
): number {
const { principal, annualRate, months } = loan;
if (principal < 0 || annualRate < 0 || months < 1) {
throw new Error("principal and annualRate must be non-negative; months must be positive");
}
// This function models completed whole monthly payments.
const k = clamp(Math.trunc(paidPayments), 0, months);
if (k === 0) return principal;
if (k === months) return 0;
const r = annualRate / 12;
if (r === 0) return principal * (1 - k / months);
const payment = annuityPayment(loan);
const growth = Math.pow(1 + r, k);
const balance = principal * growth - payment * ((growth - 1) / r);
// A tiny negative number can occur because JavaScript uses binary floats.
return Math.max(0, balance);
}
This is O(1) time and O(1) extra memory. Math.pow is not a loop over the loan periods from your application's perspective; the function does not allocate or retain 360 schedule rows.
Avoid rounding payment, growth, or balance to cents inside this function. Round only at the boundary where a number is displayed. Rounding the balance at every simulated period is exactly the sort of detail that makes two implementations disagree by a few cents.
Check it against a 30-year example
Take a principal of 1,000,000 currency units, a 3.5% annual rate, and 360 monthly payments:
const loan = {
principal: 1_000_000,
annualRate: 0.035,
months: 360,
};
console.log(annuityPayment(loan).toFixed(2));
// 4490.45
console.log(remainingAnnuityBalance(loan, 60).toFixed(2));
// 896970.73
console.log(remainingAnnuityBalance(loan, 120).toFixed(2));
// 774268.75
console.log(remainingAnnuityBalance(loan, 240).toFixed(2));
// 454104.01
It can be surprising that 60 payments of 4,490.45 do not reduce a 1,000,000 principal by 269,427. The difference is interest. In an annuity schedule, early payments contain a larger interest portion because the opening balance is still high. The formula is a concise way to expose that fact; it is not a shortcut that changes the underlying economics.
Keep a loop as a reference implementation
The closed form is ideal when you need one balance, but an iterative version is still valuable in tests. It acts as an executable definition of the recurrence:
export function remainingBalanceByIteration(
loan: FixedRateLoan,
paidPayments: number,
): number {
const k = clamp(Math.trunc(paidPayments), 0, loan.months);
const r = loan.annualRate / 12;
const payment = annuityPayment(loan);
let balance = loan.principal;
for (let period = 0; period < k; period += 1) {
const interest = balance * r;
const principalPaid = payment - interest;
balance = Math.max(0, balance - principalPaid);
}
return balance;
}
This reference implementation is O(k) time but still uses O(1) extra memory because it holds only the current balance. Building an array of rows is a separate choice. It is appropriate for a table or CSV export, but not required just to find a balance.
Test formula, boundaries, and invariants
Formula code benefits from tests that compare two implementations rather than tests that only repeat the formula. Here is a Vitest example:
import { describe, expect, it } from "vitest";
const loan = {
principal: 1_000_000,
annualRate: 0.035,
months: 360,
};
describe("remainingAnnuityBalance", () => {
it("matches an iterative recurrence at several checkpoints", () => {
for (const paid of [0, 1, 60, 120, 240, 359, 360]) {
expect(remainingAnnuityBalance(loan, paid)).toBeCloseTo(
remainingBalanceByIteration(loan, paid),
7,
);
}
});
it("returns known balances for the 3.5%, 30-year example", () => {
expect(annuityPayment(loan)).toBeCloseTo(4490.45, 2);
expect(remainingAnnuityBalance(loan, 60)).toBeCloseTo(896970.73, 2);
expect(remainingAnnuityBalance(loan, 120)).toBeCloseTo(774268.75, 2);
});
it("handles a zero-rate installment loan", () => {
const zeroRateLoan = { principal: 120_000, annualRate: 0, months: 12 };
expect(remainingAnnuityBalance(zeroRateLoan, 0)).toBe(120_000);
expect(remainingAnnuityBalance(zeroRateLoan, 5)).toBe(70_000);
expect(remainingAnnuityBalance(zeroRateLoan, 12)).toBe(0);
});
it("does not return a negative balance outside the term", () => {
expect(remainingAnnuityBalance(loan, -3)).toBe(loan.principal);
expect(remainingAnnuityBalance(loan, 999)).toBe(0);
});
});
The test has two complementary parts. Known-value assertions catch an accidental change in the business definition. Comparing with the recurrence catches algebra or indexing mistakes, such as treating k as payments remaining instead of payments completed.
Know when not to use the shortcut
The closed form is correct only while its assumptions remain true. Do not use it unchanged when the loan has:
- a variable rate or a rate reset;
- an irregular first period or non-monthly payment dates;
- fees added to each payment;
- a partial prepayment that changes future payment or term;
- payment holidays, arrears, or a balloon payment; or
- equal-principal rather than fixed-payment amortization.
For those cases, model the actual cash-flow events in chronological order. The good news is that you still do not need to store every intermediate row unless the product needs to display them. A reducer over events can keep just the current balance while applying each event.
Use the right computation for the screen
A full amortization schedule is the right output for “show me every payment.” A closed form is the right tool for “show me the balance after 60 payments,” a dashboard checkpoint, or a what-if calculation that would otherwise create disposable arrays on every keystroke.
To verify the same fixed-rate, monthly annuity inputs interactively, try this remaining mortgage balance calculator. Compare the result with a lender's schedule, keeping in mind that actual first-period day counts, rounding rules, rate changes, and prepayments can produce a different contractual balance.
The main implementation lesson is simple: retain a full schedule when a feature needs the rows; use the formula when it only needs one state. Both should agree under the same assumptions.
Top comments (0)