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 famous "every four years" version of the leap-year rule is an approximation that the Gregorian calendar deliberately corrects, and the correction is exactly what a naive year % 4 === 0 check gets wrong. A solar year is about 365.2422 days, so adding a day every four years slightly over-corrects; the Gregorian reform therefore removes the leap day in century years, except every fourth century. The full rule is: divisible by 4, but not by 100 unless also by 400. So 1900 is not a leap year (divisible by 100, not by 400) while 2000 is (divisible by 400). A % 4 check is right for the overwhelming majority of years and wrong precisely at these century boundaries โ a low-frequency bug that can lurk for a long time.
Because the failures are rare, they are also insidious: code that computes February's length, day-of-year, or age with a % 4-only rule will produce a wrong answer only around century years, which almost never appear in test data. That is why it is worth either using the full three-part predicate deliberately or, better, deferring to a calendar type that already encodes the correct rule, so the correction is never something a developer has to remember.
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
The three-condition predicate โ (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 โ is the correct, self-contained expression, and it is worth writing once as a named function with a comment rather than inlining the arithmetic where a reader might "simplify" it back to % 4. But in a codebase already using Temporal, you rarely need the predicate at all: Temporal.PlainDate.from({ year, month: 2, day: 1 }).daysInMonth === 29 answers "is this a leap year" by consulting the calendar, and PlainYearMonth and other calendar types expose inLeapYear directly. Deferring to the calendar means the rule lives in one authoritative place.
Preferring the calendar also generalizes correctly beyond the Gregorian assumption. "Leap year" means different things in different calendars โ the Hebrew calendar's leap year adds a whole month, not a day โ so a hard-coded Gregorian predicate is simply wrong for non-Gregorian dates, whereas inLeapYear on a date in the relevant calendar reports that calendar's notion. If your application only ever deals in Gregorian years, the predicate is fine; the moment calendars enter the picture, reading inLeapYear from the typed date is the only approach that stays correct.
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
The dominant pitfall is the % 4-only check, which is wrong for century years not divisible by 400 (1900, 2100, 2200) โ rare enough to survive testing and reach production. Use the full three-part rule or a calendar type's inLeapYear/daysInMonth. The second pitfall is applying a Gregorian leap-year predicate to a non-Gregorian date, where "leap year" may mean an added month rather than an added day; read inLeapYear from the date in its actual calendar instead. The third is duplicating the arithmetic across a codebase, where one copy inevitably gets "simplified" or mistyped; centralize it in one named function or defer to the calendar.
A subtler mistake is conflating "leap year" with "has a February 29" in edge reasoning โ they coincide in the Gregorian calendar, but the robust way to ask whether a specific February 29 exists is daysInMonth === 29 for that year, which reads the calendar directly. Finally, watch for integer-vs-string year inputs: a year arriving as '2000' will pass % 4 after implicit coercion in some contexts and fail in others, so validate and coerce the year to a number before applying any predicate, or pass it through PlainDate/PlainYearMonth, which parse strictly.
- 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.
How do I check if a year is a leap year in JavaScript?
Use the full Gregorian rule: (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0. This correctly excludes century years not divisible by 400, so 1900 is not a leap year but 2000 is. In a Temporal codebase you can instead read it from the calendar: Temporal.PlainDate.from({ year, month: 2, day: 1 }).daysInMonth === 29, or inLeapYear on a calendar-bearing type, which keeps the rule in one authoritative place.
Why is checking year % 4 === 0 not enough?
Because the Gregorian calendar removes the leap day in century years to correct the slight over-count of a pure four-year rule โ except every fourth century. So 1900, 2100, and 2200 are divisible by 4 but are not leap years, while 2000 (divisible by 400) is. A % 4-only check is wrong precisely at these century boundaries, a rare case that rarely appears in test data and so reaches production.
Does the leap-year rule differ in non-Gregorian calendars?
Yes. 'Leap year' is calendar-specific: the Hebrew calendar's leap year inserts an entire extra month rather than a single day, and other calendars have their own rules. A hard-coded Gregorian predicate is wrong for non-Gregorian dates. Reading inLeapYear from a Temporal date in its actual calendar reports that calendar's own notion, which is the only correct approach once non-Gregorian calendars are involved.