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} центів @ $local")
// приклад: #1 Coffee: 350 cents @ 2026-01-14T13:05:00.123
}
З погляду архітектури це маленький, але важливий крок: усередині системи ми зберігаємо момент (Instant), а під час показу користувачеві отримуємо локальні компоненти (LocalDateTime) через явно обрані правила (TimeZone). Саме такий сценарій «Instant як джерело істини → LocalDateTime для UI» вважається базовим і рекомендованим.
9. Типові помилки
Помилка № 1: зберігати момент події як LocalDateTime і «припускати» часовий пояс.
LocalDateTime виглядає зручно («і дата, і час!»), тому його часто беруть як універсальний тип. Проблема в тому, що без поясу він не говорить, коли саме це було. Сьогодні ви припускали «за часом сервера», завтра сервер переїхав в інший регіон — і раптом «усі події стали на три години раніше». 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. Коли ви обираєте тип за змістом, половина майбутніх багів просто не виникає, бо компілятор не дає вам змішувати незмішуване.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ