Stop miscalculating age in JavaScript: leap years, Feb 29, and the Jan 31 trap

Most age calculators are wrong for at least one of these reasons:

they do nowYear – dobYear and forget to check if the birthday already happened

they treat all months like the same length

they explode on Feb 29 birthdays

they hit JavaScript’s “Jan 31 + 1 month = March” surprise

If you want an age calculator you can ship, you need a small amount of boring correctness.

This post shows a simple, testable way to calculate years + months + days between two calendar dates.

What we’re actually trying to compute

Given:

dob (date of birth)

asOf (the date you want to measure age on, defaulting to today)

We want:

years: full birthdays completed

months: full months since the last birthday

days: remaining days since that month anchor

If asOf < dob, that’s invalid input.

The two rules that prevent 90% of bugs
Rule 1: Work with “date-only”, not time

Dates in JS carry time and timezone baggage. For age, you almost always want midnight local time.

So normalize:

YYYY-MM-DD 00:00:00

Rule 2: Define your Feb 29 policy

Born on Feb 29, non-leap year: do you count their birthday on Feb 28 or Mar 1?

There’s no universal answer. Pick one and be consistent.
In this code: Feb 28.

The algorithm (simple and dependable)

compute tentative years = asOf.year – dob.year

if asOf is before birthday in asOf.year, subtract 1

set anchor = last birthday date

walk forward month-by-month from anchor, clamping day-of-month

remaining days = difference between anchor and asOf

Implementation (TypeScript)
type AgeBreakdown = { years: number; months: number; days: number };

function normalizeDateOnly(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}

function daysInMonth(year: number, monthIndex0: number) {
return new Date(year, monthIndex0 + 1, 0).getDate();
}

function birthdayInYear(
dob: Date,
year: number,
feb29Rule: “FEB_28” | “MAR_1” = “FEB_28”,
) {
const m = dob.getMonth();
const day = dob.getDate();

// Feb 29 handling
if (m === 1 && day === 29) {
const isLeap = new Date(year, 1, 29).getMonth() === 1;
if (isLeap) return new Date(year, 1, 29);
return feb29Rule === “FEB_28” ? new Date(year, 1, 28) : new Date(year, 2, 1);
}

return new Date(year, m, day);
}

export function calculateAge(dobInput: Date, asOfInput: Date): AgeBreakdown {
const dob = normalizeDateOnly(dobInput);
const asOf = normalizeDateOnly(asOfInput);

if (asOf < dob) throw new Error(“asOf must be >= dob”);

// Years
let years = asOf.getFullYear() – dob.getFullYear();
const bdayThisYear = birthdayInYear(dob, asOf.getFullYear(), “FEB_28”);
if (asOf < bdayThisYear) years -= 1;

// Anchor at last birthday
const lastBirthdayYear = dob.getFullYear() + years;
let anchor = birthdayInYear(dob, lastBirthdayYear, “FEB_28”);

// Months: step forward with month-end clamping
let months = 0;
while (true) {
const nextMonthFirst = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 1);
const y = nextMonthFirst.getFullYear();
const m = nextMonthFirst.getMonth();
const d = Math.min(anchor.getDate(), daysInMonth(y, m));
const candidate = new Date(y, m, d);

if (candidate <= asOf) {
  months += 1;
  anchor = candidate;
} else break;

}

// Days
const msPerDay = 24 * 60 * 60 * 1000;
const days = Math.floor((asOf.getTime() – anchor.getTime()) / msPerDay);

return { years, months, days };
}
Tests that catch the real failures

Don’t just test “normal” birthdays. Test the annoying dates.

import { describe, it, expect } from “vitest”;
import { calculateAge } from “./calculateAge”;

describe(“calculateAge”, () => {
it(“handles birthday not yet happened this year”, () => {
const dob = new Date(“2000-10-20”);
const asOf = new Date(“2026-02-09”);
const r = calculateAge(dob, asOf);
expect(r.years).toBe(25);
});

it(“handles month-end clamping (Jan 31)”, () => {
const dob = new Date(“2000-01-31”);
const asOf = new Date(“2000-03-01”);
const r = calculateAge(dob, asOf);
// If your month add is buggy, this often breaks.
expect(r.years).toBe(0);
expect(r.months).toBeGreaterThanOrEqual(1);
});

it(“handles Feb 29 birthdays with FEB_28 rule”, () => {
const dob = new Date(“2004-02-29”);
const asOf = new Date(“2025-02-28”);
const r = calculateAge(dob, asOf);
// Under FEB_28 policy, birthday is considered reached on Feb 28.
expect(r.years).toBe(21);
});

it(“rejects asOf before dob”, () => {
const dob = new Date(“2020-01-01”);
const asOf = new Date(“2019-12-31”);
expect(() => calculateAge(dob, asOf)).toThrow();
});
});

You can add more:

dob = 1999-12-31, asOf = 2000-01-01

dob = 2000-02-28, asOf = 2001-02-28

dob = 2000-03-31, asOf = 2000-04-30

The takeaway

If you want correct age output:

normalize to date-only

define Feb 29 behavior

clamp month ends

ship tests for weird dates

That’s it. No libraries required.

Demo (optional): https://www.calculatorhubpro.com/everyday-life/age-calculator

Leave a Reply