Работа с датами в JavaScript: как сделать правильно
Парсинг строки без часового пояса, сложение месяцев, разница между датами — три места, где нативный Date молча даёт неверный результат. Разбираем, где он ломается и чем закрыть.

Строка new Date("2024-03-10") в браузере в Москве вернёт не то, что вы ожидаете: дата будет интерпретирована как полночь по UTC, а при выводе в локальном времени сдвинется. Прибавить месяц через setMonth к 31 января — получите 3 марта, а не 28 февраля. Посчитать разницу в днях простым вычитанием и делением на 86 400 000 — иногда получите дробное число из-за перехода на летнее время. Ниже — где нативный Date ведёт себя не так, как кажется, и какие подходы дают предсказуемый результат.
Три места, где Date даёт неверный результат
Проблема не в том, что Date сломан. Он делает ровно то, что заложено в спецификацию ECMAScript, — просто это поведение неочевидно и зависит от часового пояса машины, где выполняется код. Именно поэтому баг с датами воспроизводится у одного разработчика и не воспроизводится у другого.
1. Парсинг строки без пояса
Формат YYYY-MM-DD (только дата, без времени) по спецификации трактуется как UTC. А формат с временем без указания зоны — как локальное время. Одна и та же на вид строка ведёт себя по-разному:
// Браузер в зоне UTC+3
new Date("2024-03-10").toISOString();
// "2024-03-10T00:00:00.000Z" — полночь UTC
// при выводе в локали покажет 03:00 10 марта
new Date("2024-03-10T00:00:00").toISOString();
// "2024-03-09T21:00:00.000Z" — трактовано как локальное,
// сдвинуто в UTC на -3 часа
Если вы храните только дату (день рождения, срок оплаты) и парсите её как UTC, а потом форматируете в локальном времени пользователя западнее UTC, дата съедет на день назад.
2. Арифметика с переполнением
Методы setMonth и setDate нормализуют переполнение молча:
const date = new Date(2024, 0, 31); // 31 января 2024
date.setMonth(date.getMonth() + 1); // хотим "февраль"
date.toLocaleDateString("ru-RU"); // "02.03.2024"
// 31 февраля не существует → перенос на 3 марта
Для «прибавить месяц, оставшись в пределах месяца» нативного способа нет — нужно проверять, не съехал ли день, и откатывать на последний день месяца вручную.
3. Разница между датами и переход на летнее время
В зонах, где действует переход на летнее время (в России его отменили в 2011 году, но пользователи из других стран есть), сутки могут длиться 23 или 25 часов. Разница в миллисекундах, делённая на 86 400 000, даст дробь:
// Только для зон с DST, например America/New_York
const start = new Date("2024-03-10T00:00:00-05:00");
const end = new Date("2024-03-11T00:00:00-04:00");
const days = (end - start) / 86400000;
// 0.9583..., а не 1 — сутки перехода короче на час
Что делать вместо этого
Парсинг: всегда указывайте формат явно
Для строк только с датой разбирайте компоненты сами и стройте локальную дату конструктором с числами — он трактует аргументы как локальное время без сюрпризов:
function parseLocalDate(str) {
// ожидаем "YYYY-MM-DD"
const [year, month, day] = str.split("-").map(Number);
// month минус 1: в конструкторе месяцы с нуля
return new Date(year, month - 1, day);
}
parseLocalDate("2024-03-10").toLocaleDateString("ru-RU");
// "10.03.2024" в любой зоне — без сдвига
Так вы получаете полночь по локальному времени, а не по UTC, и дата не съезжает при форматировании.
Арифметика: библиотека или Temporal
Ручная арифметика с Date — источник тонких багов. Есть три взрослых пути.
Если у вас в проекте больше пяти мест, где вы что-то прибавляете к дате или считаете разницу, — это сигнал вынести работу с датами в один модуль на одной библиотеке, а не размазывать setMonth по коду.
date-fns — набор чистых функций, каждая импортируется отдельно, дерево зависимостей режется бандлером. Работает с нативным Date, не оборачивает его в свой тип.
// date-fns 3.x
import { addMonths, differenceInCalendarDays } from "date-fns";
addMonths(new Date(2024, 0, 31), 1);
// 29 февраля 2024 — библиотека сама откатывает
// на последний день месяца
differenceInCalendarDays(
new Date(2024, 2, 11),
new Date(2024, 2, 10)
);
// 1 — считает по календарным дням, а не по часам
Temporal — новый встроенный API, который решает большинство проблем Date на уровне языка: неизменяемые объекты, явное разделение «дата», «дата со временем» и «момент во времени», честная работа с зонами. На конец 2024 года находится в Stage 3 и в браузерах доступен пока в основном за флагом или через полифил @js-temporal/polyfill. Проверяйте поддержку в вашей среде выполнения перед тем, как закладывать в прод без полифила.
Хранение: UTC, отображение — локаль пользователя
Правило, которое снимает большую часть путаницы: на бэкенде и в базе храните момент времени в UTC (ISO-строка с Z или timestamp), а часовой пояс применяйте только при выводе пользователю. Не смешивайте: если начали хранить локальное время «как есть», рано или поздно два пользователя из разных зон увидят одно событие в разное время и не поймут друг друга.
Сравнение подходов
| Подход | Плюсы | Минусы |
|---|---|---|
| Нативный Date + свои функции | Ноль зависимостей | Легко получить тонкий баг с зоной или переполнением |
| date-fns | Tree-shaking, работает с Date, много функций | Часовые пояса — отдельный пакет date-fns-tz |
| Temporal (встроенный) | Без зависимостей в перспективе, честные зоны и неизменяемость | Ещё не везде доступен без полифила |
| Luxon | Зоны и локали из коробки, свой класс DateTime | Больше размер бандла, свой тип вместо Date |
Когда что выбирать
- Простой проект, нужно прибавить дни и отформатировать — date-fns, импортируя только нужные функции.
- Много работы с часовыми поясами, показываете события пользователям из разных стран — Luxon или date-fns-tz, где зоны продуманы.
- Новый проект без жёстких требований к старым браузерам, готовы подключить полифил — присмотритесь к Temporal: за ним будущее, и переучиваться потом не придётся.
- Одно-два места с датами и нет желания тянуть зависимость — свои функции поверх
Date, но покрытые тестами именно на граничные случаи: конец месяца, високосный год, разные зоны.
Что чаще всего ломается
- Месяцы с нуля. В конструкторе
new Date(2024, 0, 1)— это январь, аgetMonth()вернёт 0. Забыть про это — классика. - Мутирующие сеттеры.
setDateменяет исходный объект, а не возвращает новый. Если передалиDateв функцию и там вызвали сеттер — вы испортили дату у вызывающего кода. - Сравнение объектов. Два
Dateс одинаковым временем не равны через===. Сравнивайте черезgetTime()или+date. - toLocaleDateString без зоны на сервере. Node берёт зону из переменной окружения
TZили системы. На разных серверах вывод разный — фиксируйте зону явно параметромtimeZone.
Что сделать дальше
Заведите в проекте один модуль с датами и запретите себе вызывать setMonth и парсить строки вне него. Напишите тесты именно на граничные случаи: 31 января плюс месяц, високосный 2024 год, дата без времени в зоне западнее UTC. Если начинаете новый фронтенд — попробуйте @js-temporal/polyfill на пилотном участке, чтобы оценить API до того, как Temporal станет стандартом де-факто.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: MDN — Date, TC39 Temporal Proposal
Частые вопросы
А если у меня старый проект на moment.js?
Почему new Date("10.03.2024") даёт разные результаты в разных браузерах?
Не сработало: date-fns addMonths импортируется, а сборка падает
Как надёжно получить дату в конкретном часовом поясе, а не в зоне сервера?
Стоит ли уже использовать Temporal в проде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.