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 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.

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)

Full Production Version

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"

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.

Common Pitfalls

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.