1. Вступ
Коли ми починаємо працювати з датою та часом, раптово відкривається неприємна правда: рядок зручний доти, доки ви лише виводите його в консоль. Але щойно вам потрібно порівняти дати, відсортувати події або перевірити коректність введення, рядок перетворюється на хиткий ґрунт. Тож потрібна дисципліна: дату й час зберігаємо окремо — типом, а рядок використовуємо лише для введення та виведення.
Парсинг — це перетворення рядка на тип (LocalDate, LocalDateTime, Instant). Форматування — зворотна операція: перетворення типу на рядок. Ці операції схожі на вхід і вихід у метро: переплутали — і ви вже не там, ще й із відчуттям, ніби хтось украв ваш здоровий глузд.
У цій лекції важливо втримати одну думку: ми не «зберігаємо дату рядком». Ми зберігаємо дату як дату, а рядок робимо лише «обгорткою» для людини.
2. ISO-8601 і parse()
Якби програмісти з усього світу зібралися й домовилися про єдиний формат дати, то, найімовірніше, це був би ISO‑8601. Він виглядає не дуже «по‑людськи», зате дуже «по‑машинному»: 2026-01-14 читається однозначно, сортується передбачувано й не змушує вгадувати, що посередині — місяць чи день. У kotlinx.datetime багато типів уміють парсити саме ISO‑рядки через parse().
Одразу домовімося про практичне правило: для обміну та зберігання використовуємо ISO (те, що розуміє parse()), а для відображення людині — можемо зробити свій формат (наприклад, dd.MM.yyyy). Таке розділення — як «внутрішня мова» застосунку та «мова інтерфейсу».
Невеликий приклад із LocalDate:
import kotlinx.datetime.LocalDate
fun main() {
val d = LocalDate.parse("2026-01-14")
println(d) // 2026-01-14
}
Тут println(d) викликає toString() (під капотом), а toString() для LocalDate зазвичай повертає ISO‑форму.
А ось приклад із Instant:
import kotlinx.datetime.Instant
fun main() {
val t = Instant.parse("2026-01-14T12:30:00Z")
println(t) // 2026-01-14T12:30:00Z
}
Зверніть увагу на Z: це «UTC». Для Instant важливо, щоб рядок задавав момент часу однозначно (наприклад, через Z або зміщення). Інакше ви отримаєте «локальний годинник без правил переведення», а це вже зовсім інша сутність.
Чому parse() може падати — і чому це нормально
Слова «може кинути виняток» звучать лячно, але у світі парсингу це звичайні будні. Якщо користувач увів 2026-13-40, така дата неможлива. Якщо увів hello, це взагалі не схоже на дату. І в цей момент бібліотека має повне моральне право сказати: «Я не розумію» — через виняток.
У попередніх темах ми вже бачили схожу історію з числами: toInt() може кинути NumberFormatException, якщо рядок не є числом. Тому для користувацького введення краще використовувати toIntOrNull().
З датою та часом ідея та сама. Тільки замість toIntOrNull() у нас зазвичай буде «власна функція …OrNull», побудована на runCatching.
3. Безпечний парсинг: try/catch і runCatching
Коли ви пишете консольний застосунок, користувацьке введення — це зовнішній світ. А зовнішній світ, як відомо, не зобовʼязаний бути акуратним. Тому парсинг має бути безпечним: некоректне введення не повинно «валити» застосунок. У Kotlin є класичний шлях через try/catch і компактніший — через runCatching.
Варіант через try/catch
У цьому підході ми явно перехоплюємо виняток і повертаємо null, якщо парсинг не вдався:
import kotlinx.datetime.LocalDate
fun parseIsoDateOrNull(input: String): LocalDate? {
return try {
LocalDate.parse(input.trim())
} catch (e: Exception) {
null
}
}
Цей код читається нормально, але швидко розростається, якщо таких парсерів стає багато.
Варіант через runCatching
runCatching дозволяє компактно записати ідею «спробуй — якщо не вийшло, не падай»:
import kotlinx.datetime.LocalDate
fun parseIsoDateOrNull(input: String): LocalDate? =
runCatching { LocalDate.parse(input.trim()) }.getOrNull()
Тут getOrNull() повертає результат у разі успіху або null у разі помилки. Це ідеально для шаблону «прочитав → підготував → розпарсив → перевірив».
Історично навколо runCatching навіть є нюанси контрактів і поведінки у складніших випадках — це видно з журналів змін Kotlin. Але на нашому рівні важливе просте: runCatching допомагає не перетворювати код на ліс із try/catch.
Чому ми повертаємо T?, а не рядок помилки
Можна спокуситися й повертати текст помилки: "Невірний формат дати". Але це швидко призводить до плутанини: функція наче парсить, а насправді повертає або дату, або текст. Контракт T? простіший: або тип вийшов, або null. А повідомлення користувачеві — це вже турбота шару введення/виведення (CLI), який вирішить, що саме друкувати.
4. toString() як формат за замовчуванням
Коли ви виводите обʼєкт у println, Kotlin automatically викликає toString(). Для типів із kotlinx.datetime toString() зазвичай повертає ISO‑подання. Це зручно з двох причин: воно однозначне, і його потім можна знову розпарсити через parse().
Тобто ви отримуєте природний «кругообіг даних» у застосунку:
flowchart LR
A["Тип дати/часу"] -->|"toString()"| B["Рядок (зазвичай ISO)"]
B -->|"parse()"| A
У цьому колі особливо важливо, що тип лишається типом, а рядок — рядком, і кожен інструмент робить свою роботу.
Приклад:
import kotlinx.datetime.LocalDate
fun main() {
val original = LocalDate.parse("2026-01-14")
val text = original.toString()
val parsedBack = LocalDate.parse(text)
println(text) // 2026-01-14
println(parsedBack) // 2026-01-14
}
У цей момент зазвичай зʼявляється думка: «Чудово, тоді завжди показуватимемо ISO користувачеві». І… інколи це справді нормально. Але часто користувачеві звичніше 14.01.2026, і тоді починається користувацьке форматування.
5. Користувацький формат і введення
Форматування «для людини» — це не про розумну математику, а про акуратне складання рядка. У базовому курсі ми не використовуватимемо складні форматери й локалізації. Натомість зробимо зрозумілу функцію форматування вручну, використовуючи padStart, інтерполяцію та поля дати.
Формат dd.MM.yyyy для LocalDate
import kotlinx.datetime.LocalDate
fun formatRu(date: LocalDate): String {
val dd = date.dayOfMonth.toString().padStart(2, '0')
val mm = date.monthNumber.toString().padStart(2, '0')
return "$dd.$mm.${date.year}"
}
fun main() {
val d = LocalDate.parse("2026-01-04")
println(formatRu(d)) // 04.01.2026
}
Тут важливо, що ми робимо централізоване правило. Якщо завтра ви вирішите, що потрібен формат dd/MM/yyyy, ви зміните одну функцію, а не 25 println по всьому проєкту.
Формат dd.MM.yyyy HH:mm для LocalDateTime
LocalDateTime містить і дату, і час. Ми можемо розширити форматування:
import kotlinx.datetime.LocalDateTime
fun formatRu(dt: LocalDateTime): String {
val dd = dt.dayOfMonth.toString().padStart(2, '0')
val mm = dt.monthNumber.toString().padStart(2, '0')
val hh = dt.hour.toString().padStart(2, '0')
val min = dt.minute.toString().padStart(2, '0')
return "$dd.$mm.${dt.year} $hh:$min"
}
fun main() {
val dt = LocalDateTime.parse("2026-01-14T09:05:00")
println(formatRu(dt)) // 14.01.2026 09:05
}
Ми не намагаємося «перемогти всі формати світу». Ми робимо один конкретний формат, який потрібен нашому застосунку, — і це цілком нормальна інженерна практика.
Користувацьке введення: парсимо dd.MM.yyyy безпечно
Тепер неприємна частина: LocalDate.parse() не зобовʼязаний розуміти формат 14.01.2026. Тому для користувацького формату ми робимо окремий парсер.
Тут легко потрапити в пастку «та я зараз на split за пʼять секунд зроблю». І так, зробите. Але потім зʼясується, що 32.01.2026 теж «розпарсилося», а це вже не дата, а фанфік за календарем. Тож правильніше діяти так: розібрати числа, а потім створити LocalDate через конструктор і перехопити виняток, якщо дата неможлива.
import kotlinx.datetime.LocalDate
fun parseRuDateOrNull(input: String): LocalDate? {
val parts = input.trim().split(".")
if (parts.size != 3) return null
val day = parts[0].toIntOrNull() ?: return null
val month = parts[1].toIntOrNull() ?: return null
val year = parts[2].toIntOrNull() ?: return null
return runCatching { LocalDate(year, month, day) }.getOrNull()
}
Зверніть увагу: toIntOrNull() — це знайомий нам «безпечний парсинг числа», який запобігає NumberFormatException. У документації про винятки цей підхід показано як рекомендовану альтернативу небезпечному toInt().
«Розумний» парсер: приймаємо ISO або dd.MM.yyyy
У реальних застосунках часто хочеться бути добрими до користувача й приймати кілька варіантів формату. Але доброта має бути контрольованою — інакше ви почнете «вгадувати» введення та випадково трактувати одне як інше.
Компромісний варіант: спочатку пробуємо ISO (як найсуворіший і однозначний), потім пробуємо формат dd.MM.yyyy.
import kotlinx.datetime.LocalDate
fun parseDateFlexibleOrNull(input: String): LocalDate? {
val s = input.trim()
return runCatching { LocalDate.parse(s) }.getOrNull()
?: parseRuDateOrNull(s)
}
Цей код добрий тим, що не розмазує логіку по всьому проєкту. В ідеалі саме такі функції й мають жити у вашому «шарі введення/парсингу»: маленькі, одноцільові, зі зрозумілим контрактом T?.
6. Вбудовуємо в застосунок: додаємо дату операції
Уявімо, що наш практичний консольний проєкт — це простий облік витрат. Раніше витрата могла складатися із суми та категорії. Тепер додамо дату операції як LocalDate. Наша мета тут не «зробити супер‑застосунок», а показати, як парсинг і форматування перестають бути хаосом, коли їх винесено в окремі функції.
Модель даних Expense з датою
import kotlinx.datetime.LocalDate
data class Expense(
val amount: Int,
val category: String,
val date: LocalDate,
)
Так, тут лише три поля. І так, це вже робить застосунок на порядок зрозумілішим: дата — не рядок, а саме LocalDate.
Введення витрати: читаємо дату рядком і парсимо
import kotlinx.datetime.LocalDate
fun readExpenseDate(): LocalDate {
while (true) {
print("Дата (YYYY-MM-DD або dd.MM.yyyy): ")
val input = readln()
val date = parseDateFlexibleOrNull(input)
if (date != null) return date
println("Не вдалося розпізнати дату. Приклад: 2026-01-14 або 14.01.2026")
}
}
Тут цикл робить саме те, що має: не «випрошує ідеальне введення», а спокійно перепитує. І головне — парсинг не розкиданий по коду, він живе в parseDateFlexibleOrNull.
Гарне виведення списку витрат: форматуємо дату окремо
fun printExpense(e: Expense) {
val dateText = formatRu(e.date)
println("${dateText} | ${e.category} | ${e.amount}")
// 14.01.2026 | Food | 1200
}
У результаті виведення стає «людським», а дані всередині лишаються «машинними».
7. Повідомлення про помилки: акуратно не губимо причину
Іноді дуже хочеться пояснити користувачеві, «чому» не розпарсилося: наприклад, «місяць має бути від 1 до 12». Для цього під час налагодження можна дістати виняток із runCatching, але робити так всюди й завжди не варто: ви швидко почнете друкувати моторошні тексти на кшталт IllegalArgumentException: Invalid date..., які зрозумілі бібліотеці, але не людині.
Документація про винятки добре показує, що винятки бувають різними, а їхній текст — це передусім інструмент розробника, а не інтерфейс для користувача. Тому зазвичай роблять так: користувачеві — коротко й ясно, а детальну причину — в лог (якщо він є) або в налагоджувальне виведення.
Міні‑варіант «дістати причину» (наприклад, для налагодження):
import kotlinx.datetime.LocalDate
fun parseIsoDateDebug(input: String): LocalDate? {
val result = runCatching { LocalDate.parse(input.trim()) }
result.exceptionOrNull()?.let { e ->
println("DEBUG: parse error = ${e.message}")
}
return result.getOrNull()
}
Цей підхід корисний, але ним важливо не зловживати: інакше ви перетворите консольний застосунок на потік «страждань JVM».
8. Типові помилки
Помилка № 1: парсити дату «як вийде» через split, але не перевіряти календарну коректність.
Часто новачки розбивають рядок на частини, перетворюють їх на числа й вважають, що на цьому все. Проблема в тому, що так можна прийняти 99.99.2026 як «валідну дату» на рівні типів Int. Правильніше — після розбору чисел усе одно створити LocalDate(...) і перехопити помилку через runCatching, щоб календар сказав своє чесне «ні».
Помилка № 2: викликати parse() напряму на користувацькому введенні без обробки помилок.
LocalDate.parse(...) і Instant.parse(...) розраховані на коректний рядок. Зовнішнє введення майже ніколи не гарантує коректності, тому код починає падати «у найнедоречніший момент» і псує UX. Натомість краще обгортати парсинг у runCatching { ... }.getOrNull() і повертати null, а далі вже спокійно перепитувати користувача.
Помилка № 3: змішувати «як зберігати» і «як показувати».
Якщо ви починаєте зберігати дату вже відформатованим рядком 14.01.2026, то далі самі собі ускладнюєте життя: сортування, порівняння, фільтри — усе знову перетворюється на роботу з рядками. Значно стійкіше зберігати LocalDate, а форматування робити лише перед виведенням — окремою функцією на кшталт formatRu(date).
Помилка № 4: розносити форматування по всьому коду замість однієї функції.
Коли формат дати розмазаний по println, за тиждень ви отримаєте три різні вигляди дати в одному застосунку: десь 2026-01-14, десь 14.1.2026, десь ще й без провідних нулів. Виправлення перетворюється на археологію. Централізована функція форматування розвʼязує це одразу й дисциплінує проєкт.
Помилка № 5: намагатися зробити «універсальний парсер усього» надто рано.
Іноді хочеться підтримати десять форматів, пробіли, слеші, коми та місячний календар. На практиці це перетворює введення на вгадування, а код — на набір крихких евристик. На базовому рівні краще тримати один формат обміну (ISO через parse()) і один формат для користувача (наприклад, dd.MM.yyyy). А якщо ви підтримуєте обидва — робіть це строго й передбачувано, як у parseDateFlexibleOrNull.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ