1. Вступ
Майже всі починають однаково: «ну дата ж приходить як текст — значить, я й зберігатиму її як текст». І справді: користувач вводить 2026-01-06, у файлі це теж буде рядок, у JSON — теж рядок… То чому б не залишити так у коді? Саме на цьому місці багато програм «усміхаються», кивають і… тихо готують вам сюрпризи в продакшені.
Проблема в тому, що рядок — це просто набір символів. Компілятор Kotlin бачить у String лише текст. Він не знає, що там «дата», не перевіряє межі, не вміє додавати «плюс один день» і не гарантує коректних порівнянь «раніше/пізніше». У результаті виникає типова ситуація: дані виглядають як дати, а поводяться як звичайний текст (бо це і є звичайний текст).
Розберімо три великі класи проблем:
- порівняння рядків не дорівнює порівнянню дат;
- рядок не гарантує валідності дати;
- формати можуть бути неоднозначними, і «один і той самий» запис може означати різні дати.
2. Порівняння рядків: чому дата стає «словом»
Коли ми порівнюємо рядки, Kotlin робить це лексикографічно: приблизно як слова у словнику — зліва направо, за символами. Така поведінка вбудована в String і загалом у механізм порівняння об’єктів, які мають «природний порядок». Для рядків цей порядок саме лексикографічний.
І ось тут починається магія рівня «чому моя бухгалтерія вирішила, що лютий раніше за січень».
Приклад: два «гарні» формати, один баг
Виглядає як дата, але порівнюється як текст:
fun main() {
val a = "01.02.2026"
val b = "12.01.2026"
println(a < b) // true (але за календарем 01.02.2026 пізніше за 12.01.2026)
}
Чому true? Тому що рядок "01.02.2026" починається з '0', а "12.01.2026" — з '1'. Для рядків цього вже достатньо: '0' < '1', отже весь рядок менший.
Якщо у проєкті ви сортуєте список подій за такими рядками, то отримаєте «гарно відсортований хаос».
«Але в мене ISO-формат — там усе нормально!»
Так, існує формат, який випадково доволі непогано «дружить» із лексикографічним порівнянням, — yyyy-MM-dd (рік-місяць-день). Він часто поводиться «правильно» під час порівняння рядків, але це не робить його типом дати.
fun main() {
val iso1 = "2026-01-06"
val iso2 = "2026-12-31"
println(iso1 < iso2) // true (і тут збіглося з календарем)
}
Чому збіглося? Тому що більш значущі частини (рік, потім місяць, потім день) стоять лівіше. Але це все одно порівняння тексту. Достатньо найменшого відхилення від формату — і «магія» зламається.
Мікропастка: «без провідних нулів» усе ламається
Дуже поширена ситуація: хтось вирішив зберігати місяць як 1, а не 01. Зовні — «ну яка різниця, людина ж зрозуміє». А от сортування — не зрозуміє.
fun main() {
val d1 = "2026-1-10"
val d2 = "2026-01-11"
println(d1 < d2) // false (а за календарем 10 січня раніше за 11 січня)
}
Лексикографічно "2026-1-10" порівнюється не так, як "2026-01-10". І якщо ви думаєте «ми завжди писатимемо правильно», то маю для вас новини: одного дня це зробить користувач. Або ваш колега. Або ви самі в п’ятницю ввечері.
3. Валідність: рядок дозволяє «2026-13-40» і не червоніє
Після порівняння зазвичай прилітає другий удар: рядок не гарантує, що дата взагалі існує.
fun main() {
val s = "2026-13-40"
println(s) // 2026-13-40 (виглядає впевнено, живе зухвало, але не існує)
}
Рядок не знає, що місяць має бути від 1 до 12, що в лютому не завжди 29 днів, а 31 червня — фантастика. Це не «помилка Kotlin». Так і має бути: String не зобов’язаний бути календарем.
«Тоді я сам перевірю через split!»
Звучить бадьоро. На практиці ви швидко з’ясуєте, що «дата» — це не просто три числа, а три числа плюс чимало правил.
Ось приклад дуже спрощеної (і навмисно неповної) перевірки ISO-формату:
fun isIsoDateRough(s: String): Boolean {
val parts = s.split("-")
if (parts.size != 3) return false
val year = parts[0].toIntOrNull() ?: return false
val month = parts[1].toIntOrNull() ?: return false
val day = parts[2].toIntOrNull() ?: return false
return year >= 1 && month in 1..12 && day in 1..31
}
Ця функція корисна як навчальна сходинка, але вона пропускає масу «неможливих» дат: 2026-02-31, 2026-04-31, 2026-11-00 і так далі. Щоб зробити по-справжньому правильно, вам доведеться реалізувати правила календаря, високосні роки, кількість днів у кожному місяці… І в якийсь момент ви зрозумієте, що винаходите календар заново. Зазвичай це невигідна угода.
Чому «регулярний вираз» теж не рятує
Можна написати Regex("""\d{4}-\d{2}-\d{2}"""), і він гарантуватиме лише те, що рядок виглядає як 2026-13-40. Тобто регулярний вираз перевіряє «чи схоже на дату», але не «чи є датою».
Це як перевіряти паспорт за шаблоном «дві літери й шість цифр»: схоже, але не факт, що такий документ існує.
4. Формат: «01.02.2026» — це 1 лютого чи 2 січня?
Третя велика проблема — неоднозначність форматів. Людина (і програма) мають домовитися, що означають частини дати.
Коли ви бачите 01.02.2026, це може бути:
- 1 лютого 2026 (формат dd.MM.yyyy, популярний у Європі),
- 2 січня 2026 (формат MM.dd.yyyy, поширений у США в побуті).
І найнеприємніше: обидві дати валідні, тобто простими перевірками ви помилки не спіймаєте.
fun main() {
val s = "01.02.2026"
println(s) // рядок один, а смислів — щонайменше два
}
Хороша звичка: розділяти зберігання й відображення
Навіть якщо ви поки що змушені зберігати дату рядком (наприклад, на ранній стадії проєкту), корисно домовитися про таке:
- усередині програми дата зберігається в одному строгому форматі (часто ISO yyyy-MM-dd),
- користувачеві показуємо у звичному вигляді (наприклад, dd.MM.yyyy),
- введення користувача нормалізуємо (або принаймні перевіряємо) на вході.
Це ще не фінальне рішення (ми досі не використовуємо типи дати), але вже сильний крок до адекватності.
5. Мінікейс: витрати з датою, які «не сортуються»
Уявімо, що в нас є простий консольний застосунок «Трекер витрат». Ми вже вміємо зберігати список витрат у MutableList, уміємо додавати елементи й друкувати звіти. І ось цілком логічне бажання: додати дату покупки.
Початковий варіант моделі даних часто виглядає так:
data class Expense(
val title: String,
val amount: Int,
val date: String // "як увів користувач"
)
І далі хочеться зробити «показати витрати за датою» або «відсортувати за датою».
Сортування за рядком: працює… доки не перестане
data class Expense(val title: String, val amount: Int, val date: String)
fun main() {
val expenses = listOf(
Expense("Кава", 250, "12.01.2026"),
Expense("Проїзд", 70, "02.02.2026"),
Expense("Піца", 900, "05.01.2026"),
)
val sorted = expenses.sortedBy { it.date }
println(sorted.joinToString("\n"))
}
Із високою ймовірністю ви отримаєте порядок, який з погляду календаря здаватиметься «випадковим». Тому що sortedBy сортує за тим, що ви дали, — за рядком, — а рядок порівнюється лексикографічно.
«Гаразд, зберігатимемо дату в ISO-рядку»
Це краще, ніж dd.MM.yyyy, тому що ISO частіше правильно сортується як текст. Давайте «поліпшимо» модель, але все ще залишимо рядок:
data class Expense(val title: String, val amount: Int, val isoDate: String)
fun main() {
val expenses = listOf(
Expense("Кава", 250, "2026-01-12"),
Expense("Проїзд", 70, "2026-02-02"),
Expense("Піца", 900, "2026-01-05"),
)
val sorted = expenses.sortedBy { it.isoDate }
println(sorted.map { it.isoDate + " " + it.title }.joinToString("\n"))
// 2026-01-05 Піца
// 2026-01-12 Кава
// 2026-02-02 Проїзд
}
Це вже схоже на перемогу. Але важливо чесно собі сказати: ми просто обрали формат, який вдало «маскує» проблему порівняння. Валідність усе ще не гарантована, арифметика «плюс 1 день» усе ще ручна, а «час події» (якщо він знадобиться) одразу принесе новий біль.
6. Чому рядок — погана внутрішня модель
У цьому розділі важливо трохи пригальмувати й сформулювати думку, яка потім супроводжуватиме вас усю кар’єру: внутрішні типи програми мають відображати зміст даних. Ввід і вивід майже завжди рядкові, але всередині програми мають жити змістові сутності.
Рядки зручні у трьох місцях:
- користувацьке введення (readln() завжди дає String);
- вивід на екран (людині потрібен текст);
- зберігання/обмін (файли, JSON, мережеві протоколи).
Але щойно ви починаєте робити «логіку» — порівняння, сортування, обчислення, перевірку коректності, — рядок стає пасткою.
Невелика таблиця: чим погана дата-рядок
| Задача | Якщо дата — |
Що йде не так |
|---|---|---|
| Порівняти «раніше/пізніше» | |
порівняння лексикографічне, залежить від формату |
| Перевірити коректність | , |
легко пропустити неможливі дати |
| Додати «+ 1 день» | вручну | потрібні правила календаря, місяці різної довжини |
| Підтримати різні формати | «якщо крапка — одне, якщо дефіс — інше» | код розростається й стає крихким |
| Опрацювати помилку формату | винятки/ |
легко «завалити» програму без акуратного опрацювання |
Як зазвичай народжується баг: «рядок протік у логіку»
Щоб закріпити, корисно побачити це як потік даних. Найчастіше помилка з’являється тоді, коли рядок (пакування) раптом стає частиною внутрішньої логіки.
flowchart TD
A[Користувач увів дату рядком] --> B[Ми зберегли String 'як є']
B --> C[Сортуємо / порівнюємо / рахуємо]
C --> D[Отримуємо баг: порядок неправильний або дата неможлива]
D --> E[Починаємо лагодити милицями: split, regex, if-else]
E --> C
Замкнене коло: що більше милиць, то складніший код — і тим більше в ньому нових багів.
7. Що робити зараз, якщо типів дати ще немає
Важливо: сьогодні ми ще не вводимо типи дати/часу. Але вже зараз можемо ухвалити кілька дисциплінувальних рішень, щоб проєкт не перетворився на «пекельний парсер календаря» з п’яти тисяч рядків.
Централізуйте правила формату: одна функція нормалізації
Зробімо маленьку утиліту: вона приймає рядок, намагається привести його до ISO yyyy-MM-dd, а якщо не може — повертає null. Це не ідеальна календарна валідація, але вже хороша практика: правила в одному місці, контракт зрозумілий.
fun normalizeIsoDateOrNull(input: String): String? {
val s = input.trim()
val parts = s.split("-")
if (parts.size != 3) return null
val year = parts[0].toIntOrNull() ?: return null
val month = parts[1].toIntOrNull() ?: return null
val day = parts[2].toIntOrNull() ?: return null
if (month !in 1..12) return null
if (day !in 1..31) return null
val mm = month.toString().padStart(2, '0')
val dd = day.toString().padStart(2, '0')
return "${year}-$mm-$dd"
}
Так, тут усе ще є спрощення «день до 31». Але ідея корисна: нормалізуємо формат і повертаємо null замість падіння.
Використовуйте «прочитав → підготував → перевірив → зберіг»
Цей патерн ви вже робили для чисел. Для дати-рядка він теж працює:
fun main() {
print("Введіть дату покупки (yyyy-M-d): ")
val raw = readln()
val iso = normalizeIsoDateOrNull(raw)
if (iso == null) {
println("Некоректна дата: '$raw'") // зрозуміле повідомлення
return
}
println("Зберігаю дату у форматі ISO: $iso") // наприклад: 2026-01-06
}
Ми не «лікуємо» календар на 100 %, але принаймні перестаємо зберігати «що завгодно».
Календарна дата й момент часу — різні сутності
У побутовому мовленні «дата/час» — одне слово. У програмуванні це щонайменше дві різні сутності:
- календарна дата: «6 січня 2026» (без часу доби);
- момент часу: «2026-01-06 10:15:30» (а інколи ще й з часовим поясом).
І якщо ви зберігаєте все рядком, то мимоволі змішуєте ці смисли. А потім дивуєтеся, чому «подія о 10:00» раптом сталася «в інший час» на іншому комп’ютері, або чому «початок дня» не можна коректно порахувати простим додаванням секунд.
Сьогодні наша мета — не розв’язати це повністю, а побачити, чому рядок як внутрішня модель приречений. У наступних лекціях ми підійдемо до розв’язання через типи дати й часу, де компілятор почне вам допомагати, а не мовчки спостерігати за пожежею.
8. Типові помилки під час роботи з датою як із рядком
Помилка №1: порівнювати дати операторами <, >, sorted() за рядком «як увів користувач».
Це майже завжди дає лексикографічний порядок, який залежить від формату й легко ламається на провідних нулях, різних роздільниках і змішаних стилях. Навіть якщо «ніби працює» на тестових даних, це крихка удача, а не гарантія.
Помилка №2: вважати ISO-рядок «повноцінною датою» і будувати на ньому арифметику.
ISO-формат справді зручний для зберігання й обміну, і він часто «правильно сортується» як текст. Але ви все ще не вмієте безпечно робити «плюс 1 день» або «різниця між датами», не написавши половину календаря вручну.
Помилка №3: перевіряти дату регулярним виразом або split і думати, що тепер усе коректно.
Перевірка форми (\d{4}-\d{2}-\d{2}) не перевіряє зміст. Дуже легко прийняти 2026-13-40 або 2026-02-31 за «нормальну дату», якщо перевіряти лише кількість цифр і роздільники.
Помилка №4: змішувати формати в одному проєкті без явного контракту.
Коли частина коду очікує dd.MM.yyyy, частина — yyyy-MM-dd, а частина взагалі «як прийшло», програма починає «інколи працювати». Це найгірший режим: вона не падає одразу, а тихо псує дані й звіти.
Помилка №5: розмазувати форматування по всьому коду замість однієї-двох функцій.
Якщо ви в десяти місцях пишете "$day.$month.$year" і в п’яти місцях парсите через split("."), то за першої ж зміни вимог (наприклад, захотіли dd/MM/yyyy) у вас буде веселе «сафарі» проєктом. Централізовані функції форматування й нормалізації — це проста звичка, яка економить години життя.
Помилка №6: не опрацьовувати «погане введення» й розраховувати, що користувач завжди введе правильно.
Будь-який парсинг і будь-яка валідація можуть закінчитися помилкою. Це не «рідкісний випадок», це нормальна гілка виконання. Якщо ви не закладаєте її в логіку (через null, runCatching, try/catch), програма або падає, або зберігає сміття — і обидва варіанти однаково сумні.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ