1. Зачем несколько типов времени
Когда начинаешь программировать, хочется верить, что время — это одна сущность: «ну вот дата и время, что тут сложного?». Но реальность напоминает Jira: сущность одна, а статусов у неё тридцать. В kotlinx.datetime нам дают несколько типов не ради издевательства, а ради точности мысли: «что именно я храню и что с этим можно делать».
Самая важная мысль этой лекции: тип выбирается по смыслу, а не по формату строки, который вам привычен.
Сведём «кто есть кто» в табличку — её полезно держать в голове:
| Тип | Про что это по смыслу | Пример из жизни | Что в нём нет |
|---|---|---|---|
|
«Момент на временной шкале» (точка времени) | когда реально был сделан платёж, записан лог, пришёл запрос | локальных часов, «даты на календаре» без зоны |
|
«Календарная дата» | дата рождения, дата дедлайна «по календарю» | времени суток, таймзоны |
|
«Локальные часы + календарь» | встреча «в 15:00 10 марта» в конкретном городе | таймзоны (поэтому может быть неоднозначно) |
|
«Правила перевода» между Instant и локальными представлениями | «Осло», «UTC», «America/New_York» | это не дата и не время, это именно правила |
Сами авторы kotlinx-datetime прямо рекомендуют выбирать тип под задачу: для событий/логов чаще Instant, для дат без времени — LocalDate, а для будущих «локальных» событий — LocalDateTime плюс отдельно хранимая зона.
Kotlin 2.3 и импорты Instant и Clock
Если вы используете Kotlin 2.3, IDE может подсказать вам import kotlin.time.Instant и import kotlin.time.Clock, и это нормально: экосистема менялась, и часть функциональности (в частности Instant/Clock) была перенесена из kotlinx-datetime в стандартную библиотеку Kotlin.
Но при этом kotlinx.datetime остаётся важным, потому что именно там живут «календарные» типы вроде LocalDate, LocalDateTime, TimeZone и удобные преобразования между «моментом» и «локальными компонентами». В этой лекции мы будем использовать связку: Instant/Clock (как момент) + kotlinx.datetime (как календарь и таймзоны).
Ещё одна взрослая ремарка: по таблице стабильности компонентов Kotlin библиотека kotlinx-datetime долгое время имела статус Alpha. Это не значит «опасно», это значит «API мог меняться», поэтому особенно важно доверять подсказкам вашей версии IDE/документации, если сигнатуры чуть отличаются.
2. Instant: момент, который не знает про ваш календарь
Когда вы видите слово Instant, полезно мысленно произнести: «момент». Не «локальное время», не «сегодня в 12:30», а именно точка на временной шкале.
Авторы kotlinx-datetime описывают Instant как универсальную отметку времени, которую удобно использовать для уже случившихся событий (логи, транзакции) или для ближайшего будущего, где правила таймзон вряд ли успеют поменяться.
Сам Instant — штука очень честная: он не пытается угадывать, в каком вы городе. Он не говорит «сейчас 19:05». Он говорит примерно «вот момент». Всё. Хотите «локальные часы» — принесите таймзону.
Мини-пример: получим «сейчас» как момент времени.
import kotlin.time.Clock
import kotlin.time.Instant
fun main() {
val now: Instant = Clock.System.now()
println(now) // например: 2026-01-14T21:03:10.123Z
}
Обратите внимание: вывод часто выглядит как ISO-8601 с Z на конце. Z — это обозначение UTC в строковом представлении момента. Это не «ваши часы», это просто стандартный способ показать момент времени.
3. LocalDate: календарная дата без часов и таймзоны
LocalDate — это тип, который отвечает на вопрос «какая дата?». Именно дата: год, месяц, день. Он не знает про часы, минуты, секунды — и это не недостаток, а защита от путаницы.
Классический пример — дата рождения. Если человек родился 2001-05-20, то это дата, а не «момент времени», и привязывать её к Instant чаще всего бессмысленно (и иногда даже вредно).
Пример создания LocalDate из компонентов:
import kotlinx.datetime.LocalDate
fun main() {
val d = LocalDate(2026, 1, 6)
println(d) // 2026-01-06
}
Почему это удобнее, чем строка "06.01.2026"? Потому что теперь у нас именно дата как тип, и компилятор не даст «случайно» сложить её со строкой или сравнить как текст. (Валидацию и парсинг строк мы подробно будем делать в другой лекции дня.)
4. LocalDateTime: локальные дата и время без таймзоны
LocalDateTime звучит как «ну вот же, дата и время, победа!». И тут важно не попасть в ловушку: LocalDateTime — это локальные компоненты (год/месяц/день + часы/минуты/секунды), но без таймзоны.
Документация kotlinx-datetime формулирует это очень прямо: LocalDateTime хорош как человекочитаемое представление Instant, для передачи данных, и для планирования будущих событий «в локальном времени» (особенно если правила зон могут измениться). При этом арифметику над LocalDateTime библиотека намеренно не даёт, потому что без таймзоны легко получить «время, которого не существует» в день перехода часов.
Пример создания LocalDateTime из компонентов:
import kotlinx.datetime.LocalDateTime
fun main() {
val dt = LocalDateTime(2026, 1, 6, 10, 0, 0)
println(dt) // 2026-01-06T10:00:00
}
Здесь нет информации, в какой зоне это «10:00». В Нью-Йорке? В Берлине? В UTC? Программа не имеет права угадывать — иначе баги были бы не «иногда», а «всегда, но по расписанию».
5. TimeZone: правила перевода, без которых «10:00» не становится моментом
TimeZone — это не время и не дата. Это набор правил: как переводить «момент» (Instant) в «локальные компоненты» (LocalDateTime) и наоборот. Одна и та же точка на шкале (Instant) будет показывать разные «локальные часы» в разных зонах. Именно поэтому таймзона — обязательная часть перевода.
В kotlinx.datetime есть несколько практичных способов получить таймзону:
import kotlinx.datetime.TimeZone
fun main() {
val systemTz = TimeZone.currentSystemDefault()
val utc = TimeZone.UTC
val berlin = TimeZone.of("Europe/Berlin")
println(systemTz) // например: America/Los_Angeles
println(utc) // UTC
println(berlin) // Europe/Berlin
}
Идентификаторы зон вроде "Europe/Berlin" берутся из базы IANA time zone database. Это те самые «официальные» названия, которые вы часто видите в настройках серверов и ОС.
6. Преобразования Instant ↔ LocalDateTime через TimeZone
Теперь самое практичное: как превращать «момент» в «локальные часы» и обратно. Здесь важно запомнить правило, которое спасает от половины временных багов:
Instant можно однозначно перевести в LocalDateTime, если известна зона.
А вот LocalDateTime не всегда однозначно переводится в Instant, даже если зона известна, потому что бывают переходы времени (DST), «дыры» и «повторы» часов.
Instant -> LocalDateTime: покажи локальные часы
Этот перевод логически безопасен: момент один, зона известна, значит локальные компоненты определяются однозначно (по правилам зоны).
Пример на двух зонах — UTC и системной:
import kotlin.time.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
fun main() {
val now = Clock.System.now()
println(now.toLocalDateTime(TimeZone.UTC)) // в UTC
println(now.toLocalDateTime(TimeZone.currentSystemDefault())) // в локальной зоне
}
Ровно такой подход показан в документации/README проекта: Instant переводим в LocalDateTime через toLocalDateTime(timeZone), причём TimeZone.currentSystemDefault() — нормальный вариант для отображения «как на этом компьютере».
LocalDateTime -> Instant: интерпретируй эти локальные часы как момент
Вот здесь начинается взрослая жизнь. LocalDateTime сам по себе — «компоненты времени без зоны». Чтобы сделать из этого «момент», вы должны сказать: в какой зоне эти компоненты надо интерпретировать.
Простой пример:
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
fun main() {
val meeting = LocalDateTime(2026, 1, 6, 10, 0, 0)
val tz = TimeZone.of("UTC")
val instant = meeting.toInstant(tz)
println(instant) // 2026-01-06T10:00:00Z
}
Но важно понимать, что такая конвертация не всегда однозначна. Документация описывает три ситуации: момент может быть один (всё просто), может не существовать вообще (переход «вперёд», когда время перескакивает), или может существовать дважды (переход «назад», когда час повторяется). В неоднозначных случаях библиотека выбирает «ранний» вариант по правилам, чтобы всё равно вернуть какой-то Instant.
Это не значит «можно не думать». Это значит «библиотека спасёт от падения, но смысл решения — на вас».
7. Мини-схема: кто куда переводится
Чтобы не путаться, удобно держать мини-карту:
flowchart LR
I["Instant
момент"] -- "toLocalDateTime(timeZone)" --> LDT["LocalDateTime
локальные компоненты"]
LDT -- "toInstant(timeZone)" --> I
TZ["TimeZone
правила"] --- I
TZ --- LDT
LD["LocalDate
календарная дата"] --- LDT
Самый важный смысл этой схемы: TimeZone — не «опция», а обязательная часть преобразования «момент ↔ локальные часы».
8. Пример: начинаем хранить время событий правильно
Представим, что наше практическое консольное приложение (которое мы развиваем уже давно) — это небольшой учёт расходов: мы добавляем траты, выводим список, строим отчёты. Раньше мы могли хранить дату как строку («пользователь так ввёл»). Теперь сделаем аккуратнее: внутри программы хранить тип, а строку считать только упаковкой для ввода/вывода.
Начнём с модели. Пусть каждая операция имеет момент создания записи createdAt: Instant. Момент удобен для «логов», синхронизации и сортировок, но сортировки мы подробно будем разбирать в следующей лекции дня.
import kotlin.time.Instant
data class Expense(
val id: Int,
val title: String,
val amountCents: Int,
val createdAt: Instant,
)
Теперь напишем маленькую функцию-помощник: превратить момент createdAt в локальные «часы» для отображения пользователю. Здесь мы сознательно принимаем TimeZone параметром, чтобы не прятать «волшебную системную зону» внутри функции.
import kotlin.time.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
fun asLocalDateTime(instant: Instant, tz: TimeZone): LocalDateTime =
instant.toLocalDateTime(tz)
И используем это в печати строки (пока — простым toString(), без пользовательского формата; форматирование будет в отдельной лекции):
import kotlinx.datetime.TimeZone
fun printExpenseLine(e: Expense) {
val tz = TimeZone.currentSystemDefault()
val local = asLocalDateTime(e.createdAt, tz)
println("#${e.id} ${e.title}: ${e.amountCents} cents @ $local")
// пример: #1 Coffee: 350 cents @ 2026-01-14T13:05:00.123
}
С точки зрения архитектуры это маленький, но важный шаг: внутри системы мы храним момент (Instant), а при показе пользователю получаем локальные компоненты (LocalDateTime) через явно выбранные правила (TimeZone). Именно такой сценарий «Instant как источник истины → LocalDateTime для UI» считается базовым и рекомендованным.
9. Типичные ошибки
Ошибка №1: хранить момент события как LocalDateTime и «подразумевать» таймзону.
LocalDateTime выглядит удобно («и дата, и время!»), поэтому его часто берут как универсальный тип. Проблема в том, что без зоны он не говорит, когда именно это было. Сегодня вы подразумевали «по времени сервера», завтра сервер переехал в другой регион, и внезапно «все события стали на 3 часа раньше». Instant для факта события гораздо надёжнее, а LocalDateTime лучше держать как представление для отображения.
Ошибка №2: делать LocalDateTime -> Instant без понимания неоднозначности.
Даже при наличии зоны перевод может попасть в «дыру времени» или в «повтор времени» на переходах часов. Библиотека вернёт значение по правилам, но смысл этого решения должен быть осознанным: для расписаний, дедлайнов и юридически значимых событий иногда нужно отдельно продумывать политику. Документация прямо предупреждает, что обратное преобразование не всегда однозначно.
Ошибка №3: использовать TimeZone.currentSystemDefault() везде как единственно правильную зону.
Для интерфейса «покажи пользователю как на его компьютере» системная зона — отличный выбор. Но если вы пишете логи, синхронизируете данные, пересылаете события между машинами или сохраняете в файл для другого окружения, «системная зона» превращается в скрытую зависимость. В таких местах лучше явно передавать TimeZone параметром или договориться о стандартной зоне (часто это UTC).
Ошибка №4: думать, что Instant — это «UTC-дата и время».
Instant — это момент, а UTC — это способ представить момент в виде локальных компонент. Да, toString() часто выглядит как ISO-строка с Z, и кажется, что это «UTC-время». Но по смыслу Instant не обязан быть «в UTC», он просто лежит на временной шкале. Чтобы получить «дату/время на часах», вы всегда делаете перевод через TimeZone.
Ошибка №5: пытаться «выбрать один тип на все случаи жизни».
Иногда хочется сказать: «пусть в проекте будет только Instant, и всё». Или наоборот: «пусть везде будет LocalDateTime, мне так удобно». Но разные задачи реально требуют разных сущностей: дата рождения — это LocalDate, а момент записи лога — это Instant. Когда вы выбираете тип по смыслу, половина будущих багов просто не возникает, потому что компилятор не даёт вам смешивать несмешиваемое.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ