Check if a Year Is a Leap Year in JavaScript
A year is a leap year when it is divisible by 4, except centuries not divisible by 400 — so (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0. This is the only check you need, and it is the recipe-level answer to the broader topic in Leap Year Calculation Algorithms.
Why the naive check is wrong
The tempting one-liner year % 4 === 0 is correct for most years and silently wrong for century years. The Gregorian calendar drops three leap days every 400 years to keep the calendar aligned with the solar year, so the divisibility rule has two exceptions stacked on top of the base rule:
- 1900 is divisible by 4, so naive
%4calls it a leap year. It is not — 1900 is divisible by 100 but not by 400. - 2000 is divisible by 100, so a "skip every century" rule would call it a common year. It is a leap year — 2000 is divisible by 400.
This is exactly why the rule cannot be expressed as a single modulo. You need all three clauses: divisible by 4, not a non-400 century. Code that ships %4 works perfectly from 1901 through 2099, then quietly miscounts February days the moment it touches 1900 or 2100 — the kind of bug that surfaces in historical reporting, birthdate validation, or amortization schedules years after deployment.
The diagram below traces the decision for the four canonical test years.
Minimal working solution
The shortest correct implementation is pure arithmetic — no Date object, no allocation, no timezone exposure:
// Returns true only for years that are leap years under the Gregorian rules.
function isLeapYear(year: number): boolean {
// div by 4 AND (not a century OR a 400-century)
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
console.log(isLeapYear(2024)); // true — divisible by 4, not a century
console.log(isLeapYear(1900)); // false — century not divisible by 400
console.log(isLeapYear(2000)); // true — divisible by 400
console.log(isLeapYear(2023)); // false — not divisible by 4
Full production version
In real code the input is often untrusted — a query-string parameter, a CSV cell, a form field. Guard against non-integers and out-of-range values before applying the rule, so a fractional or NaN input fails loudly instead of returning a misleading boolean:
/**
* Validates a proleptic Gregorian year and reports whether it is a leap year.
* @throws TypeError on non-integer or out-of-range input.
*/
export function isLeapYear(year: number): boolean {
// Reject fractions, NaN, Infinity — modulo would otherwise "work" and mislead.
if (!Number.isInteger(year)) {
throw new TypeError(`Year must be an integer, received: ${year}`);
}
// Year 0 is valid in the proleptic Gregorian (ISO) calendar; negative = BCE.
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
If you are already inside the Temporal type system, you do not need the modulo at all. Temporal.PlainDate exposes inLeapYear and daysInYear, which apply the rule of whatever calendar the date carries — Gregorian by default, but also Hebrew, Islamic, and others:
import { Temporal } from '@js-temporal/polyfill';
// Month/day are irrelevant to the year-level flags; pick any valid date.
const date = Temporal.PlainDate.from({ year: 2000, month: 1, day: 1 });
console.log(date.inLeapYear); // true — boolean flag for the date's calendar
console.log(date.daysInYear); // 366 — 365 in common years, 366 in leap years
Use the modulo function for plain integer years; reach for inLeapYear / daysInYear only when you already hold a Temporal date and want the calendar-aware answer. For non-Gregorian calendars the modulo rule does not apply — the Hebrew calendar, for instance, inserts an entire leap month, so always defer to inLeapYear there.
Verification
These assertions pin the behavior at every edge — the two century traps plus an ordinary leap and common year:
console.assert(isLeapYear(2024) === true, '2024 divisible by 4');
console.assert(isLeapYear(2023) === false, '2023 not divisible by 4');
console.assert(isLeapYear(1900) === false, '1900: century, not /400');
console.assert(isLeapYear(2000) === true, '2000: century divisible by 400');
console.assert(isLeapYear(2100) === false, '2100: next century trap');
// Cross-check against Temporal's calendar-aware flag.
console.assert(
Temporal.PlainDate.from({ year: 1900, month: 1, day: 1 }).inLeapYear === false,
'Temporal agrees 1900 is common'
);
Common pitfalls
- Using only
year % 4 === 0. Wrong for 1900 and 2100. Wrong:return year % 4 === 0;Right:return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; - Skipping every century. Treating all
% 100 === 0years as common drops 2000. Wrong:return year % 4 === 0 && year % 100 !== 0;Right: add|| year % 400 === 0so 400-centuries pass. - Deriving the answer from
new Date(year, 1, 29). The constructor coerces to local time, so a midnight boundary can shift Feb 29 across a day in edge cases. Wrong:return new Date(year, 1, 29).getMonth() === 1;Right: use the modulo function orTemporal.PlainDate(...).inLeapYear. - Passing a non-integer.
isLeapYear(2024.5)returns a mathematically valid but meaningless boolean. Validate withNumber.isIntegerfirst.
Frequently Asked Questions
Is 2000 a leap year and 1900 not?
Correct. Both are divisible by 100, but the rule says a century year is a leap year only if it is also divisible by 400. 2000 / 400 = 5 exactly, so 2000 is a leap year. 1900 is not divisible by 400, so it is a common year of 365 days. The next century trap is 2100, which is also a common year.
Should I use the modulo rule or Temporal's inLeapYear?
Use the modulo function when you only have an integer year — it is O(1), allocation-free, and immune to timezones. Use Temporal.PlainDate(...).inLeapYear (or .daysInYear) when you already hold a Temporal date, or when you need the answer for a non-Gregorian calendar where the simple modulo does not apply.