Datetime #
Tanggal dan waktu adalah salah satu domain paling sulit dalam pemrograman — zona waktu yang membingungkan, daylight saving time yang mengubah offset secara tiba-tiba, perbedaan antara waktu lokal dan waktu universal, serta format yang beragam di setiap region. Java mengalami evolusi panjang dalam hal ini: java.util.Date dan Calendar yang terkenal buruk, lalu java.time (JSR-310) yang jauh lebih baik di Java 8. Kotlin hadir dengan kotlinx-datetime — library resmi JetBrains yang membawa API modern, multiplatform (JVM, JS, Native), dan idiomatik Kotlin. Artikel ini membahas seluruh ekosistem datetime di Kotlin: dari tipe-tipe dasar, operasi aritmatika, formatting, parsing, hingga penanganan zona waktu yang benar — termasuk jebakan umum yang membuat bug datetime sulit dilacak.
Setup kotlinx-datetime #
kotlinx-datetime adalah library terpisah yang perlu ditambahkan sebagai dependensi.
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
}
// Untuk Kotlin Multiplatform
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
}
}
}
Semua tipe utama ada di package kotlinx.datetime:
import kotlinx.datetime.*
Hierarki Tipe Datetime #
Sebelum menulis kode, penting memahami perbedaan antar tipe — kesalahan memilih tipe adalah sumber bug datetime yang paling umum.
flowchart TD
A["Tipe Datetime"] --> B["Tanpa Zona Waktu\n(Local)"]
A --> C["Dengan Zona Waktu\n(Absolute)"]
B --> D["LocalDate\nHanya tanggal\n2024-03-15"]
B --> E["LocalTime\nHanya waktu\n14:30:00"]
B --> F["LocalDateTime\nTanggal + waktu\n2024-03-15T14:30:00\nTanpa info timezone!"]
C --> G["Instant\nTitik waktu absolut\nMillisecond sejak Unix epoch\nSama di seluruh dunia"]
C --> H["ZonedDateTime\n(via java.time di JVM)\nInstant + TimeZone"]| Tipe | Representasi | Kapan Digunakan |
|---|---|---|
LocalDate | Tanggal saja: 2024-03-15 | Ulang tahun, deadline, jadwal |
LocalTime | Waktu saja: 14:30:00 | Jam buka toko, alarm harian |
LocalDateTime | Tanggal + waktu tanpa TZ | Event lokal, input form |
Instant | Titik absolut waktu | Log, timestamp database, API |
TimeZone | Representasi zona waktu | Konversi antar zona |
LocalDate — Tanggal Saja #
import kotlinx.datetime.*
// Membuat LocalDate
val hari = LocalDate(2024, 3, 15) // tahun, bulan, hari
val hariBulan = LocalDate(2024, Month.MARCH, 15) // dengan enum Month
// Tanggal hari ini — butuh zona waktu!
val hariIni: LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
// Parsing dari String
val parsed = LocalDate.parse("2024-03-15") // ISO 8601
val parsed2 = LocalDate.parse("15/03/2024", LocalDate.Format { dayOfMonth(); char('/'); monthNumber(); char('/'); year() })
// Property
println(hari.year) // 2024
println(hari.month) // MARCH
println(hari.monthNumber) // 3
println(hari.dayOfMonth) // 15
println(hari.dayOfWeek) // FRIDAY
println(hari.dayOfYear) // 75
// Operasi — menambah/mengurangi periode
val besok = hari.plus(1, DateTimeUnit.DAY)
val mingguDepan = hari.plus(1, DateTimeUnit.WEEK)
val bulanDepan = hari.plus(1, DateTimeUnit.MONTH)
val tahunDepan = hari.plus(1, DateTimeUnit.YEAR)
val kemarin = hari.minus(1, DateTimeUnit.DAY)
// Selisih antara dua tanggal
val mulai = LocalDate(2024, 1, 1)
val akhir = LocalDate(2024, 12, 31)
val selisihHari = mulai.until(akhir, DateTimeUnit.DAY) // 365
val selisihBulan = mulai.until(akhir, DateTimeUnit.MONTH) // 11
// Perbandingan
println(hari > LocalDate(2024, 1, 1)) // true
println(hari < LocalDate(2025, 1, 1)) // true
println(hari == LocalDate(2024, 3, 15)) // true
// Range tanggal
val rentang = LocalDate(2024, 1, 1)..LocalDate(2024, 12, 31)
println(hari in rentang) // true
Kasus Penggunaan LocalDate #
// Hitung umur dari tanggal lahir
fun hitungUmur(tanggalLahir: LocalDate): Int {
val hariIni = Clock.System.todayIn(TimeZone.currentSystemDefault())
var umur = hariIni.year - tanggalLahir.year
if (hariIni.monthNumber < tanggalLahir.monthNumber ||
(hariIni.monthNumber == tanggalLahir.monthNumber &&
hariIni.dayOfMonth < tanggalLahir.dayOfMonth)) {
umur--
}
return umur
}
val lahir = LocalDate(1995, 8, 17)
println("Umur: ${hitungUmur(lahir)} tahun")
// Cek apakah tanggal akhir pekan
fun LocalDate.isWeekend(): Boolean =
dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY
fun LocalDate.isWeekday(): Boolean = !isWeekend()
// Hitung hari kerja antara dua tanggal
fun hitungHariKerja(mulai: LocalDate, akhir: LocalDate): Int {
var hariKerja = 0
var tanggal = mulai
while (tanggal <= akhir) {
if (tanggal.isWeekday()) hariKerja++
tanggal = tanggal.plus(1, DateTimeUnit.DAY)
}
return hariKerja
}
// Hari pertama dan terakhir bulan
fun LocalDate.hariPertamaBulan() = LocalDate(year, monthNumber, 1)
fun LocalDate.hariTerakhirBulan() = plus(1, DateTimeUnit.MONTH)
.hariPertamaBulan()
.minus(1, DateTimeUnit.DAY)
val maret = LocalDate(2024, 3, 15)
println(maret.hariPertamaBulan()) // 2024-03-01
println(maret.hariTerakhirBulan()) // 2024-03-31
LocalTime — Waktu Saja #
// Membuat LocalTime
val waktu = LocalTime(14, 30, 0) // jam, menit, detik
val waktuMs = LocalTime(14, 30, 0, 500_000_000) // dengan nanosecond
// Parsing
val parsed = LocalTime.parse("14:30:00")
val parsed2 = LocalTime.parse("14:30:00.500") // dengan fraksional detik
// Property
println(waktu.hour) // 14
println(waktu.minute) // 30
println(waktu.second) // 0
println(waktu.nanosecond) // 0
// Perbandingan
println(LocalTime(9, 0) < LocalTime(17, 0)) // true (jam kerja)
println(LocalTime(12, 0) == LocalTime(12, 0)) // true
// Konversi ke nanosecond dari tengah malam
val nsFromMidnight = waktu.toNanosecondOfDay() // 52_200_000_000_000 ns
// Format waktu
fun LocalTime.formatWIB(): String = "%02d:%02d".format(hour, minute)
println(waktu.formatWIB()) // "14:30"
// Jam buka/tutup toko
data class JamOperasional(val buka: LocalTime, val tutup: LocalTime) {
fun sedangBuka(waktuSekarang: LocalTime): Boolean =
waktuSekarang in buka..tutup
}
val jamToko = JamOperasional(LocalTime(9, 0), LocalTime(21, 0))
println(jamToko.sedangBuka(LocalTime(14, 30))) // true
println(jamToko.sedangBuka(LocalTime(22, 0))) // false
LocalDateTime — Tanggal dan Waktu Lokal #
LocalDateTime menggabungkan tanggal dan waktu tapi tidak menyimpan informasi zona waktu. Ini bukan titik waktu absolut — dua orang di zona berbeda yang punya LocalDateTime yang sama tidak sedang berada di momen yang sama.
// Membuat LocalDateTime
val dt = LocalDateTime(2024, 3, 15, 14, 30, 0)
val dt2 = LocalDateTime(
date = LocalDate(2024, 3, 15),
time = LocalTime(14, 30, 0)
)
// Parsing
val parsed = LocalDateTime.parse("2024-03-15T14:30:00")
val parsed2 = LocalDateTime.parse("2024-03-15T14:30:00.500")
// Property
println(dt.date) // 2024-03-15
println(dt.time) // 14:30
println(dt.year) // 2024
println(dt.month) // MARCH
println(dt.hour) // 14
// Operasi
val dtBesok = dt.plus(1, DateTimeUnit.DAY)
val dtPlusJam = dt.plus(2, DateTimeUnit.HOUR)
// Konversi ke Instant — butuh timezone!
val tz = TimeZone.of("Asia/Jakarta")
val instant: Instant = dt.toInstant(tz)
// Dari Instant ke LocalDateTime
val dtKembali: LocalDateTime = instant.toLocalDateTime(tz)
// ANTI-PATTERN: menyimpan LocalDateTime di database sebagai representasi waktu absolut
// LocalDateTime "2024-03-15T14:30:00" di Jakarta ≠ di London!
// BENAR: simpan Instant ke database
// LocalDateTime berguna untuk:
// - Input form tanggal-waktu dari pengguna
// - Event yang memang lokal (konser di kota tertentu)
// - Template jadwal (setiap Senin pukul 09:00, tanpa peduli timezone)
Instant — Titik Waktu Absolut #
Instant merepresentasikan titik waktu yang spesifik di garis waktu universal — sama di seluruh dunia. Ini adalah tipe yang harus digunakan untuk menyimpan timestamp.
// Instant sekarang
val sekarang: Instant = Clock.System.now()
println(sekarang) // 2024-03-15T07:30:00Z (UTC)
// Dari Unix epoch milliseconds (dari database atau API)
val dariEpochMs = Instant.fromEpochMilliseconds(1_710_487_800_000L)
val dariEpochSec = Instant.fromEpochSeconds(1_710_487_800L)
// Ke Unix epoch
val epochMs: Long = sekarang.toEpochMilliseconds()
val epochSec: Long = sekarang.epochSeconds
// Parsing dari ISO 8601
val parsed = Instant.parse("2024-03-15T07:30:00Z")
val parsedOffset = Instant.parse("2024-03-15T14:30:00+07:00")
// Operasi — tambah/kurang Duration
val satuJamLalu: Instant = sekarang.minus(1.hours)
val besok: Instant = sekarang.plus(24.hours)
val semingguLagi: Instant = sekarang.plus(7.days)
// Selisih antara dua Instant menghasilkan Duration
val mulai = Instant.parse("2024-03-15T08:00:00Z")
val akhir = Instant.parse("2024-03-15T17:30:00Z")
val durasi: Duration = akhir - mulai
println(durasi) // 9h 30m
// Perbandingan
println(mulai < akhir) // true
println(sekarang > mulai) // true (asumsi sekarang setelah mulai)
// Konversi ke zona waktu lokal
val tzJakarta = TimeZone.of("Asia/Jakarta")
val waktuJakarta: LocalDateTime = sekarang.toLocalDateTime(tzJakarta)
val tzLondon = TimeZone.of("Europe/London")
val waktuLondon: LocalDateTime = sekarang.toLocalDateTime(tzLondon)
// Instant yang sama, waktu lokal berbeda
println(waktuJakarta) // 14:30 (WIB = UTC+7)
println(waktuLondon) // 07:30 (saat tidak daylight saving)
Clock — Abstraksi untuk Testability #
// ANTI-PATTERN: Clock.System.now() langsung di business logic
class PesananService {
fun buatPesanan(items: List<Item>): Pesanan {
return Pesanan(
items = items,
dibuat = Clock.System.now() // tidak bisa di-test!
)
}
}
// BENAR: inject Clock sebagai dependency
class PesananService(private val clock: Clock = Clock.System) {
fun buatPesanan(items: List<Item>): Pesanan {
return Pesanan(
items = items,
dibuat = clock.now()
)
}
}
// Test dengan waktu yang dikontrol
@Test
fun `pesanan harus punya timestamp yang benar`() {
val waktuTetap = Instant.parse("2024-03-15T10:00:00Z")
val clockPalsu = object : Clock {
override fun now() = waktuTetap
}
val service = PesananService(clock = clockPalsu)
val pesanan = service.buatPesanan(listOf(itemDummy))
assertEquals(waktuTetap, pesanan.dibuat)
}
TimeZone — Zona Waktu #
// Zona waktu berdasarkan ID IANA
val tzJakarta = TimeZone.of("Asia/Jakarta") // WIB: UTC+7
val tzMakassar = TimeZone.of("Asia/Makassar") // WITA: UTC+8
val tzJayapura = TimeZone.of("Asia/Jayapura") // WIT: UTC+9
val tzUTC = TimeZone.UTC
val tzLokal = TimeZone.currentSystemDefault() // timezone sistem
// Daftar semua timezone yang tersedia
val semuaTZ = TimeZone.availableZoneIds
println("${semuaTZ.size} timezone tersedia")
// Konversi Instant ↔ LocalDateTime
val sekarang = Clock.System.now()
val localJakarta = sekarang.toLocalDateTime(tzJakarta)
val localMakassar = sekarang.toLocalDateTime(tzMakassar)
val localJayapura = sekarang.toLocalDateTime(tzJayapura)
println("WIB: $localJakarta")
println("WITA: $localMakassar")
println("WIT: $localJayapura")
// UTC offset saat ini
val offsetSekarang = tzJakarta.offsetAt(sekarang)
println("Offset Jakarta: $offsetSekarang") // +07:00
// Konversi antara dua timezone
fun konversiTimezone(
waktu: LocalDateTime,
dari: TimeZone,
ke: TimeZone
): LocalDateTime {
return waktu.toInstant(dari).toLocalDateTime(ke)
}
val meetingJakarta = LocalDateTime(2024, 3, 15, 14, 0)
val meetingLondon = konversiTimezone(
meetingJakarta,
TimeZone.of("Asia/Jakarta"),
TimeZone.of("Europe/London")
)
println("Meeting Jakarta: $meetingJakarta → London: $meetingLondon")
// Meeting Jakarta: 2024-03-15T14:00 → London: 2024-03-15T07:00 (UTC+0)
Formatting dan Parsing #
import kotlinx.datetime.format.*
// Format bawaan — toString() menghasilkan ISO 8601
val tgl = LocalDate(2024, 3, 15)
println(tgl) // 2024-03-15
println(tgl.toString()) // 2024-03-15
val dt = LocalDateTime(2024, 3, 15, 14, 30, 0)
println(dt) // 2024-03-15T14:30
val instant = Instant.parse("2024-03-15T07:30:00Z")
println(instant) // 2024-03-15T07:30:00Z
// Format kustom dengan LocalDate.Format builder
val formatIndonesia = LocalDate.Format {
dayOfMonth()
char(' ')
monthName(MonthNames.ENGLISH_FULL) // atau buat custom MonthNames
char(' ')
year()
}
println(tgl.format(formatIndonesia)) // "15 March 2024"
// Format angka saja
val formatAngka = LocalDate.Format {
dayOfMonth()
char('/')
monthNumber()
char('/')
year()
}
println(tgl.format(formatAngka)) // "15/03/2024"
// Nama bulan dalam Bahasa Indonesia
val namaBulanID = MonthNames(
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember"
)
val namaHariID = DayOfWeekNames(
"Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"
)
val formatLengkapID = LocalDate.Format {
dayOfWeek(namaHariID)
chars(", ")
dayOfMonth()
char(' ')
monthName(namaBulanID)
char(' ')
year()
}
println(tgl.format(formatLengkapID)) // "Jumat, 15 Maret 2024"
// Format LocalDateTime
val formatDTID = LocalDateTime.Format {
dayOfMonth()
char('/')
monthNumber()
char('/')
year()
chars(" pukul ")
hour()
char(':')
minute()
}
println(dt.format(formatDTID)) // "15/03/2024 pukul 14:30"
// Parsing dengan format kustom
val tglParsed = LocalDate.parse("15/03/2024", formatAngka)
println(tglParsed) // 2024-03-15
Interoperabilitas dengan java.time (JVM) #
Jika proyek sudah menggunakan java.time, kotlinx-datetime menyediakan konversi yang mudah.
// Konversi kotlinx-datetime → java.time
val instant: Instant = Clock.System.now()
val javaInstant: java.time.Instant = instant.toJavaInstant()
val localDate: LocalDate = LocalDate(2024, 3, 15)
val javaLocalDate: java.time.LocalDate = localDate.toJavaLocalDate()
val localDT: LocalDateTime = LocalDateTime(2024, 3, 15, 14, 30)
val javaLocalDT: java.time.LocalDateTime = localDT.toJavaLocalDateTime()
// Konversi java.time → kotlinx-datetime
val backToKotlin: Instant = javaInstant.toKotlinInstant()
val backToDate: LocalDate = javaLocalDate.toKotlinLocalDate()
// Berguna saat bekerja dengan library Java yang memakai java.time
// misalnya JDBC, Hibernate, atau library lain
fun simpanKeDatabase(pesanan: Pesanan, koneksi: java.sql.Connection) {
val stmt = koneksi.prepareStatement("INSERT INTO pesanan (dibuat) VALUES (?)")
stmt.setObject(1, pesanan.dibuat.toJavaInstant()
.atOffset(java.time.ZoneOffset.UTC))
stmt.execute()
}
Pola Idiomatik dalam Kode Produksi #
Timestamp di Database #
// ANTI-PATTERN: simpan LocalDateTime di database
data class Transaksi(
val id: Long,
val jumlah: Double,
val waktu: LocalDateTime // timezone mana? Tidak jelas!
)
// BENAR: simpan Instant untuk timestamp absolut
data class Transaksi(
val id: Long,
val jumlah: Double,
val dibuat: Instant, // absolut, tidak ambigu
val diupdate: Instant
)
// Repository layer — konversi dari/ke epoch millisecond
fun bacaTransaksi(row: ResultSet): Transaksi = Transaksi(
id = row.getLong("id"),
jumlah = row.getDouble("jumlah"),
dibuat = Instant.fromEpochMilliseconds(row.getLong("dibuat_ms")),
diupdate = Instant.fromEpochMilliseconds(row.getLong("diupdate_ms"))
)
fun simpanTransaksi(t: Transaksi, stmt: PreparedStatement) {
stmt.setLong(3, t.dibuat.toEpochMilliseconds())
stmt.setLong(4, t.diupdate.toEpochMilliseconds())
}
Period — Rentang Kalender yang Sadar Bulan #
// DatePeriod untuk representasi periode kalender (tahun, bulan, hari)
// Berbeda dari Duration yang linear — 1 bulan bisa 28, 29, 30, atau 31 hari
val periode = DatePeriod(years = 1, months = 6, days = 15)
val mulai = LocalDate(2024, 1, 1)
val akhir = mulai.plus(periode)
println(akhir) // 2024-07-16
// DateTimePeriod — periode yang mencakup waktu juga
val periodeWaktu = DateTimePeriod(months = 3, hours = 2)
// Hitung period antara dua tanggal
val ulangTahun = LocalDate(1995, 8, 17)
val hariIni = LocalDate(2024, 3, 15)
val umur = ulangTahun.periodUntil(hariIni)
println("${umur.years} tahun ${umur.months} bulan ${umur.days} hari")
Validasi Tanggal #
// Parsing dengan error handling
fun parseTanggal(input: String): LocalDate? = try {
LocalDate.parse(input)
} catch (e: IllegalArgumentException) {
null
}
// Validasi format Indonesia DD/MM/YYYY
fun parseTanggalIndonesia(input: String): LocalDate? {
val format = LocalDate.Format {
dayOfMonth(); char('/'); monthNumber(); char('/'); year()
}
return try {
LocalDate.parse(input, format)
} catch (e: IllegalArgumentException) {
null
}
}
parseTanggalIndonesia("15/03/2024") // 2024-03-15
parseTanggalIndonesia("31/02/2024") // null (31 Februari tidak ada)
parseTanggalIndonesia("abc") // null
// Validasi rentang tanggal
fun validasiRentang(mulai: LocalDate, akhir: LocalDate): Boolean {
return mulai <= akhir
}
fun validasiTidakMasaLalu(tgl: LocalDate): Boolean {
val hariIni = Clock.System.todayIn(TimeZone.currentSystemDefault())
return tgl >= hariIni
}
Jebakan Umum yang Harus Dihindari #
// JEBAKAN 1: Menyimpan LocalDateTime sebagai timestamp
// LocalDateTime tidak punya informasi timezone — bisa salah saat DST berubah
// GUNAKAN Instant untuk semua timestamp yang disimpan
// JEBAKAN 2: Membuat LocalDate dari "sekarang" tanpa timezone
// ANTI-PATTERN:
// val hariIni = LocalDate.now() // API ini tidak ada di kotlinx-datetime! (sengaja)
// BENAR:
val hariIni = Clock.System.todayIn(TimeZone.currentSystemDefault())
// JEBAKAN 3: Aritmatika bulan yang tidak konsisten
val akhirJanuari = LocalDate(2024, 1, 31)
val bulanDepan = akhirJanuari.plus(1, DateTimeUnit.MONTH)
println(bulanDepan) // 2024-02-29 (tahun kabisat) atau 2024-02-28
// Kotlinx-datetime menangani ini dengan benar — hasil adalah hari terakhir bulan tujuan
// JEBAKAN 4: Mengabaikan DST saat konversi
// Europe/London punya DST — offset berubah antara GMT+0 dan BST+1
val london = TimeZone.of("Europe/London")
val winter = LocalDateTime(2024, 1, 15, 12, 0).toInstant(london)
val summer = LocalDateTime(2024, 7, 15, 12, 0).toInstant(london)
println(winter.epochSeconds - summer.epochSeconds)
// Beda 3600 detik (1 jam) meski LocalDateTime sama karena DST!
// JEBAKAN 5: Parsing tanpa format eksplisit untuk format non-ISO
// ANTI-PATTERN:
// LocalDate.parse("15-03-2024") // IllegalArgumentException! Bukan ISO 8601
// BENAR:
val fmt = LocalDate.Format { dayOfMonth(); char('-'); monthNumber(); char('-'); year() }
LocalDate.parse("15-03-2024", fmt) // OK
Ringkasan #
- Pilih tipe yang tepat:
LocalDateuntuk tanggal saja,LocalTimeuntuk waktu saja,LocalDateTimeuntuk input form lokal, danInstantuntuk semua timestamp yang disimpan di database atau dikirim lewat API.Instantadalah pilihan aman untuk timestamp — merepresentasikan titik waktu absolut yang sama di seluruh dunia, tidak ambigu tentang timezone.- Selalu butuh timezone untuk konversi antara
InstantdanLocalDateTime. Gunakan ID IANA seperti"Asia/Jakarta", bukan offset statis"+07:00"— offset statis tidak memperhitungkan DST.- Inject
Clocksebagai dependency agar kode yang bergantung pada waktu sekarang mudah diuji. GunakanClock.Systemdi produksi dan implementasi kustom di test.LocalDateTimebukan timestamp absolut — dua orang di zona berbeda denganLocalDateTimeyang sama tidak sedang di momen yang sama. Jangan simpanLocalDateTimeke database sebagai timestamp.DatePeriodberbeda dariDuration—DatePeriod(months = 1)bisa berarti 28, 29, 30, atau 31 hari tergantung bulan awal. GunakanDatePerioduntuk aritmatika kalender,Durationuntuk rentang waktu linear.- Parsing dan formatting menggunakan DSL builder yang type-safe:
LocalDate.Format { dayOfMonth(); char('/'); monthNumber(); char('/'); year() }. BuatMonthNameskustom untuk nama bulan dalam Bahasa Indonesia.Clock.System.todayIn(timezone)adalah cara yang benar mendapatkan “hari ini” — selalu sertakan timezone secara eksplisit.- Interoperabilitas dengan
java.timetersedia via extension functions.toJavaInstant(),.toJavaLocalDate(),.toKotlinInstant()— berguna saat bekerja dengan library Java.- Hindari aritmatika tanggal manual (hitung sendiri jumlah hari per bulan, tahun kabisat) — biarkan kotlinx-datetime yang menanganinya dengan benar, termasuk kasus edge seperti 31 Januari + 1 bulan.