Calculate Age in Years with Temporal

To compute a whole-number age, take birthDate.until(today, { largestUnit: 'year' }) (or today.since(birthDate, โ€ฆ)) on a Temporal.PlainDate and read the .years field. Part of Temporal.Duration Arithmetic.

Why This Scenario Is Tricky

Age is one of the most common date calculations and one of the most commonly wrong, because the intuitive implementations all approximate. Dividing the elapsed days by 365 ignores leap years and drifts by a day every four years, so it eventually reports someone as a year older (or younger) than they are around their birthday. Subtracting birth year from current year ignores whether the birthday has occurred yet this year, so it is off by one for everyone who has not had their birthday. Correct age is a whole calendar years count: the number of times the birthday has come around, which depends on comparing month and day, not on any fixed day-length.

Temporal computes exactly that with birthDate.until(today, { largestUnit: 'year' }).years, which counts whole calendar years between two dates, correctly accounting for leap years and for whether the anniversary has passed. Because the operation is calendar-aware, it does not care that some years are 366 days โ€” it counts anniversaries, which is what "age" means. The remaining subtleties are which "today" to measure against (a zone-sensitive question) and how to handle the leap-day birthday, both of which a robust implementation addresses explicitly.

Age is not "days elapsed รท 365". That naive formula drifts because of leap years and silently counts an extra year for people who haven't had their birthday yet this calendar year. The correct definition is calendar-based: your age is the number of complete year-anniversaries of your birthdate that have passed. Temporal.PlainDate.until with largestUnit: 'year' computes exactly this โ€” it walks whole calendar years and returns a Temporal.Duration whose .years is the answer.

The genuinely hard case is the February 29 birthday. Someone born on 2008-02-29 has no literal birthday in non-leap years. The question becomes: in 2027, do they "turn" on February 28 or March 1? Temporal resolves the intermediate calendar math with an overflow policy, and the two reasonable choices produce different ages on those in-between days. You have to pick a rule deliberately โ€” the legal convention in many jurisdictions is "the day before March 1", i.e. February 28, but Temporal's default month/year stepping lands you on the 28th naturally, which matches that convention.

Age is calendar years, not ms/31.5MLeap days break the averageAge is calendar years, not ms/31.5M(now - dob)/31557600000fractional, driftsdob.until(now,{largestUnit:'year'})exact whole yearsDividing milliseconds by an average year mishandles leap years and birthdays.

Minimal Working Solution

import { Temporal } from '@js-temporal/polyfill';

function ageInYears(birth: string, today: Temporal.PlainDate): number {
  const birthDate = Temporal.PlainDate.from(birth);
  // largestUnit:'year' makes Temporal count whole calendar years, not days/365
  return birthDate.until(today, { largestUnit: 'year' }).years;
}

const today = Temporal.PlainDate.from('2026-06-19');
console.log(ageInYears('2000-06-19', today)); // 26 (birthday is today โ†’ counts)
console.log(ageInYears('2000-06-20', today)); // 25 (birthday is tomorrow โ†’ not yet)

Age via until()PlainDate.until with largestUnit year gives whole yearsAge via until()dob PlainDate.until(today,{largestUnit}).years

Full Production Version

A production age function works entirely on PlainDate so that no time or zone perturbs the count, taking both the birth date and "today" as PlainDate inputs. Deriving "today" is where zone matters: a person's age should tick over at midnight in their locale, not the server's, so compute today as Temporal.Now.plainDateISO(userZone) rather than a UTC date, or the birthday can appear to change a day early or late for users far from the server's zone. Passing today in as a parameter (rather than reading the clock inside the function) also keeps the function pure and testable, which matters because age logic guards things like eligibility and consent.

The February 29 birthday is the canonical edge case and a genuine product decision, not just a technical one. In non-leap years there is no 29th, so does a leap-day baby age up on February 28 or March 1? Temporal's until with the default overflow gives a consistent, defensible answer, but you should decide and document which convention your domain uses โ€” legal age-of-majority rules in some jurisdictions specify one explicitly. Whatever you choose, encode it once in the age function and test it, so every consumer inherits the same behavior rather than each re-deriving it slightly differently.

Compute "today" from an explicit time zone (never the host clock for server code), validate the birthdate, and handle the leap-day birthday explicitly.

import { Temporal } from '@js-temporal/polyfill';

interface AgeOptions {
  /** IANA zone used to determine "today" โ€” pass the user's zone, not the server's. */
  timeZone?: string;
  /** How a Feb 29 birthday resolves in non-leap years. */
  leapDayRule?: 'feb28' | 'mar1';
}

function calculateAge(birthInput: string, opts: AgeOptions = {}): number {
  const { timeZone = 'UTC', leapDayRule = 'feb28' } = opts;

  let birth: Temporal.PlainDate;
  try {
    birth = Temporal.PlainDate.from(birthInput, { overflow: 'reject' }); // reject garbage like '2000-13-40'
  } catch {
    throw new RangeError(`Invalid birthdate: ${birthInput}`);
  }

  // "Today" in the relevant zone, as a calendar date with no time component
  const today = Temporal.Now.plainDateISO(timeZone);

  if (Temporal.PlainDate.compare(birth, today) > 0) {
    throw new RangeError('Birthdate is in the future.');
  }

  // Whole calendar years between the two dates
  let years = birth.until(today, { largestUnit: 'year' }).years;

  // Feb 29 born: in a non-leap year there is no Feb 29.
  // With the 'mar1' rule we only count the year once March 1 arrives;
  // since() already lands on Feb 28, so we may need to roll back one year.
  const isLeapDayBirth = birth.month === 2 && birth.day === 29;
  if (isLeapDayBirth && leapDayRule === 'mar1') {
    const isTodayLeapYear = Temporal.PlainDate.from({ year: today.year, month: 2, day: 1 }).daysInMonth === 29;
    // On Feb 28 of a non-leap year, the 'mar1' rule says they have NOT aged yet
    if (!isTodayLeapYear && today.month === 2 && today.day === 28) {
      years -= 1;
    }
  }

  return years;
}

const ny = { timeZone: 'America/New_York' };
console.log(calculateAge('1990-01-15', ny)); // current whole-year age in NY's "today"

Robust age utilityUse today in the person's zone; handle the pre-birthday caseRobust age utilitydob + zonetoday (zone)until yearsinteger age

Verification Snippet

These assertions pin down ordinary ages and both interpretations of the Feb 29 birthday.

import { Temporal } from '@js-temporal/polyfill';

function age(birth: string, todayStr: string): number {
  return Temporal.PlainDate.from(birth)
    .until(Temporal.PlainDate.from(todayStr), { largestUnit: 'year' }).years;
}

// Ordinary birthday boundary
console.assert(age('2000-06-19', '2026-06-18') === 25, 'day before birthday โ†’ 25');
console.assert(age('2000-06-19', '2026-06-19') === 26, 'on birthday โ†’ 26');
console.assert(age('2000-06-19', '2026-06-20') === 26, 'day after birthday โ†’ 26');

// Leap-day birthday: born 2008-02-29 (leap year)
// In 2027 (non-leap): until() steps whole years and lands on Feb 28 as the anniversary.
console.assert(age('2008-02-29', '2027-02-28') === 19, 'leap-day: Feb 28 2027 โ†’ 19 (turned)');
console.assert(age('2008-02-29', '2027-02-27') === 18, 'leap-day: Feb 27 2027 โ†’ still 18');

// On a real leap year the literal birthday exists
console.assert(age('2008-02-29', '2028-02-29') === 20, 'leap-day on a leap year โ†’ 20');

console.log('all age assertions passed');

The key result: until('2008-02-29' โ†’ '2027-02-28') returns 19, because Temporal counts 19 complete year-steps and the 19th anniversary in a non-leap year falls on February 28. That matches the common legal "day before March 1" convention without extra code. If your product requires the leap-day cohort to age only on March 1, use the leapDayRule: 'mar1' branch in the production version.

Age assertionsBirthday boundaries assert correctlyAssertions that prove the edge casedob 2000-02-29 in 202424day before birthdayn-1on birthdaynleap-day dobhandled

Common Pitfalls

The first pitfall is computing age from a day count divided by 365, which ignores leap years and drifts a full day every four years, eventually reporting the wrong age near the birthday. Count whole calendar years with until({ largestUnit: 'year' }) instead. The second is the year-subtraction shortcut (thisYear - birthYear), which is off by one for anyone who has not yet had their birthday this year, because it never checks the month and day. Both approximations feel close and are wrong exactly when age matters โ€” around the birthday.

A third pitfall is deriving "today" in the wrong zone, so the birthday rolls over at the server's midnight rather than the user's, making the age change a day early or late for distant users; compute today in the user's zone. A fourth is doing the math on a zoned or instant type where a DST transition can nudge the effective date; use PlainDate so the count is purely civil. Finally, handle the February 29 birthday deliberately and consistently rather than letting it fall out of whatever the first implementation happened to do โ€” it is a documented product decision, and inconsistency across the codebase produces users whose displayed age depends on which screen they are looking at.

Age pitfallsMillisecond averages and zone driftWrongRightms / 31557600000off near birthdaysuntil({largestUnit:'year'})exactuse UTC 'today'off by a day in zonetoday in person's zonecorrect boundary

Frequently Asked Questions

Why use until/since instead of subtracting years?

birthDate.until(today, { largestUnit: 'year' }) returns a balanced Temporal.Duration that counts only complete year anniversaries, automatically accounting for whether this year's birthday has passed and for leap years. Subtracting today.year - birth.year over-counts before the birthday.

How does Temporal handle a February 29 birthday in a non-leap year?

Counting whole years with until lands the anniversary on February 28 in non-leap years, so the person is treated as having aged on Feb 28 โ€” matching the common legal convention. If you instead want them to age on March 1, special-case the Feb 28 of non-leap years and subtract one year on that single day.

Should I use since or until?

They are mirror images: birthDate.until(today) and today.since(birthDate) give the same positive duration. Use whichever reads more naturally; just keep largestUnit: 'year' on the call.

How do I calculate someone's age in years with Temporal?

Use birthDate.until(today, { largestUnit: 'year' }).years on Temporal.PlainDate values. This counts whole calendar years โ€” the number of times the birthday has come around โ€” correctly accounting for leap years and for whether this year's birthday has passed. Avoid dividing elapsed days by 365 (which drifts on leap years) and avoid subtracting birth year from current year (which is off by one before the birthday).

Why is dividing days by 365 the wrong way to compute age?

Because it ignores leap years: roughly every four years an extra day accumulates, so the day-based estimate drifts and eventually reports someone a year off right around their birthday, which is exactly when age is checked. Age is a count of whole calendar years โ€” birthday anniversaries โ€” not a fixed number of days, so use a calendar-aware operation like until({ largestUnit: 'year' }) that counts anniversaries directly.

How should I handle a February 29 birthday when calculating age?

Decide and document the convention, because non-leap years have no 29th and the choice โ€” aging up on February 28 or March 1 โ€” is a product and sometimes legal decision. Temporal's until gives a consistent default, but encode your chosen rule once in the age function and test it so every consumer behaves identically. Also compute 'today' in the user's time zone so the birthday rolls over at their midnight, not the server's.