Date & Time #
Dates and times are one of those topics that look simple but are full of traps — timezones, daylight saving time, different formats across countries, and an internal representation that often confuses. JavaScript’s built-in Date object has long been criticized for its inconsistent design: months start at 0 not 1, methods mutate the object, and timezone handling is limited. Even so, Date remains a foundation worth understanding because it’s used throughout the Node.js ecosystem. This article covers Date in depth, Intl.DateTimeFormat for correct per-locale formatting, and safe time handling patterns in production applications.
The Basic Date Object #
// creating a Date
// now
const sekarang = new Date();
console.log(sekarang); // 2024-01-15T10:30:00.000Z
// from a Unix timestamp (milliseconds since Jan 1 1970 UTC)
const dariTimestamp = new Date(1705312200000);
// from an ISO 8601 string — the safest format for parsing
const dariISO = new Date("2024-01-15T10:30:00.000Z");
// from other strings — AVOID, results differ across browsers/Node.js
const dariString = new Date("January 15, 2024"); // ✗ inconsistent across environments
// from components — CAUTION: months start at 0!
const dariKomponen = new Date(2024, 0, 15, 10, 30, 0); // 15 Jan 2024, 10:30:00
// ↑ 0 = January!
// ANTI-PATTERN: forgetting that months start at 0
const salah = new Date(2024, 1, 15); // ✗ this is February, not January!
// CORRECT: use constants for clarity, or use an ISO string
const benar = new Date("2024-01-15"); // ✓ ISO strings are unambiguous
// Date.now() — the current timestamp in milliseconds (faster than new Date())
const timestamp = Date.now();
// Date.UTC() — create a UTC timestamp from components (months still 0-indexed)
const timestampUTC = Date.UTC(2024, 0, 15, 10, 30, 0);
Reading Date Components #
Date has two sets of methods: ones working in the local timezone (getFullYear, getMonth, etc.) and ones working in UTC (getUTCFullYear, getUTCMonth, etc.).
const d = new Date("2024-01-15T10:30:45.123Z");
// components in the LOCAL timezone (results differ depending on server/client timezone)
console.log(d.getFullYear()); // 2024 (or maybe different if UTC+X)
console.log(d.getMonth()); // 0 (January — remember: 0-indexed!)
console.log(d.getDate()); // 15 (day of the month)
console.log(d.getDay()); // 1 (day of the week: 0=Sunday, 1=Monday)
console.log(d.getHours()); // depends on the local timezone
console.log(d.getMinutes()); // 30
console.log(d.getSeconds()); // 45
console.log(d.getMilliseconds()); // 123
console.log(d.getTime()); // timestamp in milliseconds (always UTC)
console.log(d.getTimezoneOffset()); // timezone offset in minutes (negative for UTC+)
// components in UTC — consistent across all timezones
console.log(d.getUTCFullYear()); // 2024
console.log(d.getUTCMonth()); // 0
console.log(d.getUTCDate()); // 15
console.log(d.getUTCHours()); // 10 — always 10, regardless of the local timezone
console.log(d.getUTCMinutes()); // 30
// day and month names — use Intl, not manual arrays
const namaHari = ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"];
const namaBulan = [
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember"
];
// ANTI-PATTERN: manual name arrays (not international)
console.log(namaHari[d.getDay()]); // ✗ only works for one language
// CORRECT: use Intl.DateTimeFormat
const formatter = new Intl.DateTimeFormat("id-ID", { weekday: "long" });
console.log(formatter.format(d)); // "Senin" — automatic per locale
Writing Date Components #
The Date object is mutable — set* methods modify the object directly, rather than creating a new one. This is a common source of bugs.
const d = new Date("2024-01-15T10:30:00Z");
// ANTI-PATTERN: directly modifying a shared object
function tambahSatuHariSalah(tanggal: Date): Date {
tanggal.setDate(tanggal.getDate() + 1); // ✗ modifies the original object!
return tanggal;
}
const tgl = new Date("2024-01-15");
const besok = tambahSatuHariSalah(tgl);
console.log(tgl.toISOString()); // "2024-01-16..." — tgl changed too!
console.log(besok.toISOString()); // "2024-01-16..."
// CORRECT: always make a copy before modifying
function tambahHari(tanggal: Date, jumlah: number): Date {
const salinan = new Date(tanggal.getTime()); // make a copy via the timestamp
salinan.setDate(salinan.getDate() + jumlah);
return salinan;
}
const tgl2 = new Date("2024-01-15");
const besok2 = tambahHari(tgl2, 1);
console.log(tgl2.toISOString()); // "2024-01-15..." — unchanged ✓
console.log(besok2.toISOString()); // "2024-01-16..."
// available set methods
const d2 = new Date();
d2.setFullYear(2025);
d2.setMonth(5); // June (0-indexed)
d2.setDate(20);
d2.setHours(14);
d2.setMinutes(30);
d2.setSeconds(0);
d2.setMilliseconds(0);
// set UTC
d2.setUTCFullYear(2025);
d2.setUTCMonth(5);
d2.setUTCHours(7); // 07:00 UTC = 14:00 WIB (UTC+7)
Date Formatting #
toString and toISOString #
const d = new Date("2024-01-15T10:30:00.000Z");
// toISOString — ISO 8601 format, always UTC, the standard format for APIs
console.log(d.toISOString());
// "2024-01-15T10:30:00.000Z"
// toLocaleDateString — date format according to the locale
console.log(d.toLocaleDateString("id-ID"));
// "15/1/2024" or "15 Januari 2024" depending on the implementation
// toLocaleTimeString — time format according to the locale
console.log(d.toLocaleTimeString("id-ID"));
// toLocaleString — date and time according to the locale
console.log(d.toLocaleString("id-ID"));
// toUTCString — RFC 7231 format (for HTTP headers)
console.log(d.toUTCString());
// "Mon, 15 Jan 2024 10:30:00 GMT"
// ANTI-PATTERN: formatting dates with manual string concatenation
function formatTanggalSalah(d: Date): string {
return d.getDate() + "/" + (d.getMonth() + 1) + "/" + d.getFullYear(); // ✗
// problems: no zero-padding, not timezone-aware
}
// CORRECT: use Intl.DateTimeFormat
function formatTanggal(d: Date, locale: string = "id-ID"): string {
return new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
}
console.log(formatTanggal(d)); // "15/01/2024"
Intl.DateTimeFormat — Professional Formatting #
Intl.DateTimeFormat is the right way to format dates — it automatically follows the conventions of each locale and timezone.
const d = new Date("2024-01-15T10:30:00.000Z");
// full format for Indonesia
const formatIndonesia = new Intl.DateTimeFormat("id-ID", {
weekday: "long", // "Senin"
day: "numeric", // "15"
month: "long", // "Januari"
year: "numeric", // "2024"
hour: "2-digit", // "17" (in WIB = UTC+7)
minute: "2-digit", // "30"
timeZone: "Asia/Jakarta",
});
console.log(formatIndonesia.format(d));
// "Senin, 15 Januari 2024 pukul 17.30"
// short format
const formatSingkat = new Intl.DateTimeFormat("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "Asia/Jakarta",
});
console.log(formatSingkat.format(d)); // "15 Jan 2024"
// time only
const formatWaktu = new Intl.DateTimeFormat("id-ID", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "Asia/Jakarta",
});
console.log(formatWaktu.format(d)); // "17.30.00 WIB"
// relative format — "3 hari yang lalu", "dalam 2 jam"
const formatRelatif = new Intl.RelativeTimeFormat("id-ID", {
numeric: "auto", // "kemarin" instead of "1 hari yang lalu"
});
console.log(formatRelatif.format(-1, "day")); // "kemarin"
console.log(formatRelatif.format(-3, "day")); // "3 hari yang lalu"
console.log(formatRelatif.format(2, "hour")); // "dalam 2 jam"
console.log(formatRelatif.format(-30, "minute")); // "30 menit yang lalu"
// helper: calculate and format relative time
function waktuRelatif(tanggal: Date, locale: string = "id-ID"): string {
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const selisihDetik = Math.round((tanggal.getTime() - Date.now()) / 1000);
const batas = [
{ unit: "year" as const, detik: 365 * 24 * 3600 },
{ unit: "month" as const, detik: 30 * 24 * 3600 },
{ unit: "week" as const, detik: 7 * 24 * 3600 },
{ unit: "day" as const, detik: 24 * 3600 },
{ unit: "hour" as const, detik: 3600 },
{ unit: "minute" as const, detik: 60 },
{ unit: "second" as const, detik: 1 },
];
for (const { unit, detik } of batas) {
const nilai = Math.round(selisihDetik / detik);
if (Math.abs(nilai) >= 1) {
return formatter.format(nilai, unit);
}
}
return "baru saja";
}
const tigaHariLalu = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
console.log(waktuRelatif(tigaHariLalu)); // "3 hari yang lalu"
Custom Formats #
For very specific formats (e.g. for file names or databases), Intl.DateTimeFormat might be too flexible. Use formatToParts() for full control:
function formatKustom(d: Date, timezone: string = "Asia/Jakarta"): {
tanggal: string; // "2024-01-15"
waktu: string; // "17:30:00"
dateTime: string; // "2024-01-15 17:30:00"
namaFile: string; // "20240115_173000"
} {
const parts = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: timezone,
}).formatToParts(d);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
const tanggal = `${get("year")}-${get("month")}-${get("day")}`;
const waktu = `${get("hour")}:${get("minute")}:${get("second")}`;
return {
tanggal,
waktu,
dateTime: `${tanggal} ${waktu}`,
namaFile: `${get("year")}${get("month")}${get("day")}_${get("hour")}${get("minute")}${get("second")}`,
};
}
const d = new Date("2024-01-15T10:30:00.000Z");
console.log(formatKustom(d));
// { tanggal: "2024-01-15", waktu: "17:30:00", dateTime: "2024-01-15 17:30:00", namaFile: "20240115_173000" }
Date Arithmetic #
// always make a copy before modifying
function salinanDate(d: Date): Date {
return new Date(d.getTime());
}
// add/subtract days
function tambahHari(d: Date, jumlah: number): Date {
const hasil = salinanDate(d);
hasil.setDate(hasil.getDate() + jumlah);
return hasil;
}
// add/subtract months — setMonth handles overflow automatically
function tambahBulan(d: Date, jumlah: number): Date {
const hasil = salinanDate(d);
hasil.setMonth(hasil.getMonth() + jumlah);
return hasil;
}
// CAUTION: tambahBulan can produce unexpected dates
const tgl31Jan = new Date("2024-01-31");
console.log(tambahBulan(tgl31Jan, 1).toISOString());
// "2024-03-02" — Feb has no 31st, overflow into March!
// for this case, clamp to the end of the month
function tambahBulanAman(d: Date, jumlah: number): Date {
const hasil = salinanDate(d);
const hariAsli = d.getDate();
hasil.setMonth(hasil.getMonth() + jumlah);
// if the day overflowed, set it to the last day of the target month
if (hasil.getDate() !== hariAsli) {
hasil.setDate(0); // setDate(0) = the last day of the previous month
}
return hasil;
}
console.log(tambahBulanAman(tgl31Jan, 1).toISOString());
// "2024-02-29" — the last day of February 2024 (a leap year)
// add/subtract years
function tambahTahun(d: Date, jumlah: number): Date {
const hasil = salinanDate(d);
hasil.setFullYear(hasil.getFullYear() + jumlah);
return hasil;
}
// difference between two dates
function selisihHari(a: Date, b: Date): number {
const ms = Math.abs(b.getTime() - a.getTime());
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
function selisihBulan(a: Date, b: Date): number {
return (
(b.getFullYear() - a.getFullYear()) * 12 +
(b.getMonth() - a.getMonth())
);
}
function selisihTahun(a: Date, b: Date): number {
return b.getFullYear() - a.getFullYear();
}
// example
const lahir = new Date("1995-03-20");
const sekarang = new Date("2024-01-15");
console.log(`Umur: ${selisihTahun(lahir, sekarang)} tahun`); // 28
console.log(`Selisih: ${selisihHari(lahir, sekarang)} hari`); // 10527
// start and end of periods
function awalHari(d: Date, timezone?: string): Date {
if (timezone) {
// use Intl to get the date in a specific timezone
const parts = new Intl.DateTimeFormat("en-CA", {
year: "numeric", month: "2-digit", day: "2-digit",
timeZone: timezone,
}).formatToParts(d);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
return new Date(`${get("year")}-${get("month")}-${get("day")}T00:00:00`);
}
const hasil = salinanDate(d);
hasil.setHours(0, 0, 0, 0);
return hasil;
}
function akhirHari(d: Date): Date {
const hasil = salinanDate(d);
hasil.setHours(23, 59, 59, 999);
return hasil;
}
function awalBulan(d: Date): Date {
const hasil = salinanDate(d);
hasil.setDate(1);
hasil.setHours(0, 0, 0, 0);
return hasil;
}
function akhirBulan(d: Date): Date {
const hasil = salinanDate(d);
hasil.setMonth(hasil.getMonth() + 1, 0); // setDate(0) = the last day of this month
hasil.setHours(23, 59, 59, 999);
return hasil;
}
Timezones #
Timezones are the most common source of Date bugs. The entire Date object stores time in UTC internally — the local timezone is only used when displaying or reading components.
flowchart LR
A["Input string\n'2024-01-15T10:30:00Z'"] -- parse --> B["Internal\nUTC timestamp\n1705312200000ms"]
B -- "getHours()\nLocal timezone" --> C["17 (WIB, UTC+7)\n10 (UTC)\n05 (New York, UTC-5)"]
B -- "toISOString()" --> D["'2024-01-15T10:30:00.000Z'\nAlways UTC"]// the most common timezone problem: parsing strings without a timezone
const tanpaTZ = new Date("2024-01-15");
// In Node.js, a date string without a time is parsed as UTC midnight!
console.log(tanpaTZ.toISOString()); // "2024-01-15T00:00:00.000Z"
// But if displayed in WIB (UTC+7):
// getHours() = 7 — a day ahead for users in UTC-8!
// a string with a time but no timezone is parsed as LOCAL time
const denganWaktu = new Date("2024-01-15T00:00:00");
// Depends on the server timezone — inconsistent!
// CORRECT: always include the timezone in parsed strings
const konsisten = new Date("2024-01-15T00:00:00Z"); // UTC
const wib = new Date("2024-01-15T00:00:00+07:00"); // WIB
const withTimezone = new Date("2024-01-15T07:00:00.000Z"); // UTC, same as WIB midnight
// get the current date in a specific timezone
function tanggalSaatIniDi(timezone: string): string {
return new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: timezone,
}).format(new Date());
}
console.log(tanggalSaatIniDi("Asia/Jakarta")); // "2024-01-15"
console.log(tanggalSaatIniDi("America/New_York")); // maybe "2024-01-14" in the evening
// converting time between timezones
function konversiTimezone(d: Date, dariTZ: string, keTZ: string): string {
// you can't really "convert" a Date to another timezone —
// a Date is always UTC. Only its display formatting differs.
return new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: keTZ,
}).format(d);
}
const sekarang = new Date("2024-01-15T10:30:00Z");
console.log(konversiTimezone(sekarang, "UTC", "Asia/Jakarta"));
// "2024-01-15, 17:30:00" (WIB = UTC+7)
console.log(konversiTimezone(sekarang, "UTC", "America/New_York"));
// "2024-01-15, 05:30:00" (EST = UTC-5)
Always store and transfer time in UTC format (ISO 8601 with the Z suffix) in databases and APIs. Converting to a local timezone only happens at the presentation layer — when displaying to users. This prevents the very common bug where times shift when data moves between servers with different timezones.Safe Date Parsing #
new Date(string) behaves inconsistently for formats other than ISO 8601. For robust parsing, always validate the result.
// safe parsing — validate after parsing
function parseTanggal(input: string): Date | null {
const d = new Date(input);
// Date("invalid") produces "Invalid Date"
// isNaN on its timestamp detects this
if (isNaN(d.getTime())) return null;
return d;
}
// parsing the DD/MM/YYYY format (common in Indonesia)
function parseTanggalID(input: string): Date | null {
const match = input.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!match) return null;
const [, hari, bulan, tahun] = match;
// create in UTC so the timezone doesn't affect it
const d = new Date(Date.UTC(
parseInt(tahun),
parseInt(bulan) - 1, // convert to 0-indexed
parseInt(hari)
));
// validate that the date is valid (e.g. 31/02/2024 doesn't exist)
if (
d.getUTCDate() !== parseInt(hari) ||
d.getUTCMonth() !== parseInt(bulan) - 1 ||
d.getUTCFullYear() !== parseInt(tahun)
) {
return null; // date overflow — invalid
}
return d;
}
console.log(parseTanggalID("15/01/2024")); // a Date object
console.log(parseTanggalID("31/02/2024")); // null — Feb 31 doesn't exist
console.log(parseTanggalID("abc")); // null — wrong format
// date range validation
function isRentangValid(mulai: Date, akhir: Date): boolean {
return mulai.getTime() <= akhir.getTime();
}
function isTanggalDiMasaDepan(d: Date): boolean {
return d.getTime() > Date.now();
}
function isUmurValid(tanggalLahir: Date, minUmur: number = 17): boolean {
const umur = selisihTahun(tanggalLahir, new Date());
return umur >= minUmur;
}
function selisihTahun(a: Date, b: Date): number {
return b.getFullYear() - a.getFullYear();
}
Common Patterns in Applications #
Timestamps for Databases #
// store as an ISO string (recommended for portability)
function sekarangISO(): string {
return new Date().toISOString();
// "2024-01-15T10:30:00.123Z"
}
// store as a Unix timestamp (an integer, more compact)
function sekarangTimestamp(): number {
return Date.now(); // milliseconds
}
function sekarangTimestampDetik(): number {
return Math.floor(Date.now() / 1000); // seconds (for JWT exp, etc.)
}
// convert between formats
function isoKeTimestamp(iso: string): number {
return new Date(iso).getTime();
}
function timestampKeISO(ms: number): string {
return new Date(ms).toISOString();
}
Range Pickers and Periods #
interface Periode {
mulai: Date;
akhir: Date;
}
function periodeHariIni(timezone: string = "Asia/Jakarta"): Periode {
const sekarang = new Date();
// get today's date in the specified timezone
const tanggalStr = tanggalSaatIniDi(timezone);
const mulai = new Date(`${tanggalStr}T00:00:00+07:00`);
const akhir = new Date(`${tanggalStr}T23:59:59.999+07:00`);
return { mulai, akhir };
}
function periodeMinggIni(): Periode {
const sekarang = new Date();
const hariIni = sekarang.getDay(); // 0=Sunday, 1=Monday...
const selisihKeSenin = hariIni === 0 ? -6 : 1 - hariIni;
const mulai = tambahHari(awalHari(sekarang), selisihKeSenin);
const akhir = tambahHari(mulai, 6);
akhir.setHours(23, 59, 59, 999);
return { mulai, akhir };
}
function periodeBulanIni(): Periode {
const sekarang = new Date();
return {
mulai: awalBulan(sekarang),
akhir: akhirBulan(sekarang),
};
}
function periode30HariTerakhir(): Periode {
const akhir = new Date();
const mulai = tambahHari(akhir, -30);
mulai.setHours(0, 0, 0, 0);
return { mulai, akhir };
}
function awalHari(d: Date): Date {
const hasil = new Date(d.getTime());
hasil.setHours(0, 0, 0, 0);
return hasil;
}
function tambahHari(d: Date, jumlah: number): Date {
const hasil = new Date(d.getTime());
hasil.setDate(hasil.getDate() + jumlah);
return hasil;
}
function awalBulan(d: Date): Date {
const hasil = new Date(d.getTime());
hasil.setDate(1);
hasil.setHours(0, 0, 0, 0);
return hasil;
}
function akhirBulan(d: Date): Date {
const hasil = new Date(d.getTime());
hasil.setMonth(hasil.getMonth() + 1, 0);
hasil.setHours(23, 59, 59, 999);
return hasil;
}
function tanggalSaatIniDi(timezone: string): string {
return new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: timezone,
}).format(new Date());
}
Caching Formatters #
Creating a new Intl.DateTimeFormat instance every time a format is called is fairly expensive. Cache frequently used instances:
// ANTI-PATTERN: creating a new formatter on every call
function formatTanggalBoros(d: Date): string {
return new Intl.DateTimeFormat("id-ID", { // ✗ a new allocation every time
day: "2-digit",
month: "long",
year: "numeric",
}).format(d);
}
// CORRECT: cache the formatter
const formatterCache = new Map<string, Intl.DateTimeFormat>();
function getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale}:${JSON.stringify(options)}`;
if (!formatterCache.has(key)) {
formatterCache.set(key, new Intl.DateTimeFormat(locale, options));
}
return formatterCache.get(key)!;
}
// frequently used formatters — define once
const fmt = {
tanggal: getFormatter("id-ID", { day: "2-digit", month: "long", year: "numeric", timeZone: "Asia/Jakarta" }),
waktu: getFormatter("id-ID", { hour: "2-digit", minute: "2-digit", timeZone: "Asia/Jakarta" }),
lengkap: getFormatter("id-ID", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "Asia/Jakarta" }),
};
console.log(fmt.tanggal.format(new Date())); // "15 Januari 2024"
console.log(fmt.waktu.format(new Date())); // "17.30"
Summary #
- Months in
Datestart at 0 — January = 0, December = 11. This is the most common source of bugs; always use ISO 8601 strings to create unambiguous Dates.Dateis mutable — always make a copy withnew Date(d.getTime())before modifying; don’t mutate a Date received as a parameter.- Store and transfer time in UTC — use
toISOString()for databases and APIs; convert to a local timezone only at the presentation layer withIntl.DateTimeFormat.- Use
Intl.DateTimeFormatinstead of manual formatting — it handles locale, timezone, and display conventions automatically; far more accurate than string concatenation or manual month name arrays.- Cache frequently used
Intl.DateTimeFormatinstances — creating a new instance per call is fairly expensive; store them in aMapor module variable.Intl.RelativeTimeFormatfor relative time — “3 hari yang lalu”, “dalam 2 jam” — more accurate and international than manual implementations.- Validate parsing results —
new Date("invalid-string")returns “Invalid Date” instead of throwing; always checkisNaN(d.getTime())after parsing.tambahBulancan overflow — Jan 31 + 1 month = Mar 2, not Feb 28/29. Use a clamp to the target month’s last day if this behavior isn’t desired.Date.now()is faster thannew Date().getTime()for timestamps — use it for critical performance like benchmarking or logging.
← Previous: Timers