Exact Age Is Harder Than It Looks, in Gregorian and Hijri Years

A job application asks for your age on the closing date, in years, months and days. You’d think that has one right answer. It doesn’t, at least not near the end of a month, and it gets more interesting when the form wants your age in Hijri years. When I built the age calculator, most of the work went into choosing rules for those cases and then testing that the code follows them.

Years, months and days is ambiguous

Counting days between two dates is exact. Counting months isn’t, because months have 28, 29, 30 or 31 days. Someone born on 31 January has no “same day” in February. So what is their age on 29 February 2024: one month, or 29 days?

Different tools answer differently. JavaScript’s own Date shows the problem. If you take 31 January 2024 and set the month to February, you get 2 March, because the date overflows from the 31st of a 29-day month into the next one. A naive age calculator built on that would say a baby born on 31 January turns one month old on 2 March.

I picked one rule and wrote it down in the code: a month after a date ends on the same day number, or on the month’s last day when the month is shorter.

export function addMonths(a: Ymd, n: number): Ymd {
  const total = a.y * 12 + (a.m - 1) + n
  const y = Math.floor(total / 12)
  const m = (total % 12) + 1
  return { y, m, d: Math.min(a.d, daysInMonth(y, m)) }
}

The month arithmetic counts months since year 0, so there’s no special case for crossing December. The Math.min is the clamping rule. And it’s always counted from the date of birth itself, never step by step. That matters. If you went 31 January → 29 February → 29 March, you’d lose two days. Counting from the original date, two months after 31 January is 31 March.

Here’s what the rule gives in 2024:

Born Age on Result
29 January 29 February 1 month, 0 days
30 January 29 February 1 month, 0 days
31 January 29 February 1 month, 0 days
31 January 1 March 1 month, 1 day
31 January 31 March 2 months, 0 days
31 January 30 April 3 months, 0 days

The first three rows look odd side by side: three different birthdays, one answer. That’s the cost of any clamping rule. The other rules have costs too (an overflow rule skips days, a “30-day month” rule drifts), and I preferred the one that matches how people count monthly anniversaries. The page’s FAQ explains it in one sentence, because a person checking their own age should know which rule was used.

The algorithm

With addMonths in place, the age is two steps. Count the whole months from the birth date to the other date, then the days left over:

export function span(from: Ymd, to: Ymd): Span {
  let months = (to.y - from.y) * 12 + (to.m - from.m)
  if (compare(addMonths(from, months), to) > 0) months--
  const days = dayNumber(to) - dayNumber(addMonths(from, months))
  return { years: Math.floor(months / 12), months: months % 12, days }
}

The first guess for the months comes from the year and month numbers alone. If adding that many months overshoots the target date (it’s the 10th and you were born on the 18th), it takes one back. The leftover days are a plain subtraction of day numbers. Twelve months make a year.

Two smaller decisions keep this correct:

  • Dates have no time. Every date is a plain year, month and day, turned into a day number with Date.UTC. Nothing is ever in local time, so a daylight saving change can’t make a day 23 hours long and shift an answer by one.
  • “Today” comes from your clock, after the page loads. The HTML is built once on the server, long before you open it, so the “Age on” date is filled in from your device in onMounted. Until then every result row shows a dash, which keeps the layout from moving.

The tests pin the edges: the day before a birthday is 25 years, 11 months and 29 days, not 26; a date of birth after the “Age on” date is refused with a message; and 29 February 2023 is rejected as a date that doesn’t exist.

29 February birthdays

Someone born on 29 February 2004 has a real birthday only in leap years. In 2025, the clamping rule moves their anniversary to 28 February: on 27 February 2025 they’re 20, and on 28 February they turn 21. Their next birthday after today is shown as 28 February 2027.

This is the same rule as the month ends above, not a special case, and that’s why I like it. But it’s a choice. Some countries’ laws use 1 March instead for legal age, so the FAQ tells people with a legal deadline to check the rule that applies to them.

Hijri age with Intl

Writing a Hijri calendar from scratch would be a project of its own, and the Umm al-Qura calendar doesn’t follow a simple arithmetic rule. Browsers already include it, through Intl.DateTimeFormat with the calendar extension -u-ca-islamic-umalqura. So the converter asks the browser to format a date in that calendar and reads the numbers back:

let hijriFormat: Intl.DateTimeFormat | null = null
export function toHijri(a: Ymd): Ymd {
  hijriFormat ??= new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', { timeZone: 'UTC', year: 'numeric', month: 'numeric', day: 'numeric' })
  const parts = hijriFormat.formatToParts(new Date(dayNumber(a) * DAY))
  const get = (type: string) => Number.parseInt(parts.find(p => p.type === type)?.value ?? '', 10)
  return { y: get('year'), m: get('month'), d: get('day') }
}

formatToParts returns the day, month and year as separate pieces, so there’s no string parsing that would break with another locale. The formatter is created once and reused. The time zone is UTC to match the day numbers. The Arabic date next to it (“٧ جمادى الأولى ١٤٤٨ هـ” for 18 October 2026) comes from the same calendar with an ar-EG locale.

The Hijri age then follows the same idea as the Gregorian one, with one difference. Hijri months are 29 or 30 days, and which one depends on the calendar’s tables, so the code can’t use a fixed daysInMonth. When it needs to borrow days from the previous month, it asks the calendar: the day before the 1st of this month is the last day of the previous one, and its day number is that month’s length. A test covers a birth on 29 Ramadan 1446 (29 March 2025), the last day of a 29-day Ramadan. The next day, 1 Shawwal, the age is exactly 1 day.

Why a Hijri age runs ahead

A Hijri year has 12 lunar months and 354 or 355 days, about 11 days fewer than a Gregorian year. Those days add up. Over about 33 Gregorian years, they make one extra Hijri year.

So for most adults the Hijri age is a year higher than the Gregorian age, and two years higher from about age 66:

Born Age on 18 October 2026 Hijri age
14 February 1995 (14 Ramadan 1415) 31 years, 8 months, 4 days 32 years, 7 months, 23 days
18 October 1960 66 years, 0 months, 0 days 68 years, 0 months, 10 days

It matters for forms. A regulation written in Hijri years can count someone as old enough months before the Gregorian calendar does, so check which calendar a form asks for before filling it in.

The moon-sighting caveat

Umm al-Qura is the official calendar in Saudi Arabia, and it’s computed in advance. In countries where each month starts when the new moon is sighted, such as Egypt, the first day of a month can fall a day earlier or later than Umm al-Qura says. The page says this right under the Hijri results: a Hijri date of birth can be a day off, and so can a Hijri age counted in days. A one-day shift changes the day count, and only rarely the months or years.

Try it

The age calculator shows the exact age on any date, totals in months, weeks and days, the weekday you were born on, your next birthday, and your Hijri date of birth and Hijri age. Other everyday calculators are in calculators.

Tools in this article

  • Age calculator

    Calculate your exact age from your date of birth in years, months and days, on today or any date. Also shows your next birthday and your age in Hijri years.

More articles