Date & Time #
Working with dates and times is one of the most bug-prone areas in programming — not just in TypeScript, but in almost every language. JavaScript’s built-in Date object (inherited by TypeScript) carries many historical quirks: zero-based months, an inconsistent constructor, poor timezone support, and surprising mutability. TypeScript doesn’t fix the weaknesses of Date itself, but its type system can help prevent a number of common mistakes — especially when you define branded types to distinguish local vs UTC dates, or timestamps vs durations. This article covers how to work with Date safely in TypeScript, the traps to avoid, and when to switch to more modern solutions.
Creating Date Objects
#
There are four ways to create a Date object, each with different behavior:
// 1. No arguments — the current time
const sekarang: Date = new Date();
// 2. From a Unix timestamp (milliseconds since January 1, 1970 UTC)
const dariTimestamp: Date = new Date(1_746_576_000_000);
// 3. From an ISO 8601 string — the most recommended way
const dariISO: Date = new Date("2025-05-07T08:00:00Z"); // UTC
const dariISOLokal: Date = new Date("2025-05-07T15:00:00+07:00"); // WIB
// 4. From numeric components — CAREFUL: the month is zero-based!
const dariKomponen: Date = new Date(2025, 4, 7, 15, 0, 0);
// ↑ Month 4 = MAY (not April!)
// year, month(0-11), day, hour, minute, second
The Biggest Trap: Zero-Based Months #
This is the most common source of bugs when working with Date:
// ANTI-PATTERN: Entering the month as seen on a calendar
const ulangTahun = new Date(1995, 11, 25); // Not November! This is DECEMBER
// ^^^ 11 = December (0=Jan, 1=Feb, ..., 11=Dec)
// CORRECT: Use named constants for clarity
const BULAN = {
JANUARI: 0, FEBRUARI: 1, MARET: 2, APRIL: 3,
MEI: 4, JUNI: 5, JULI: 6, AGUSTUS: 7,
SEPTEMBER: 8, OKTOBER: 9, NOVEMBER: 10, DESEMBER: 11,
} as const;
const hariNatal = new Date(2025, BULAN.DESEMBER, 25);
// Or even better: use an ISO string to avoid ambiguity entirely
const hariNatalISO = new Date("2025-12-25"); // Clear, can't go wrong
Validating Date Objects
#
The Date constructor doesn’t throw an error for invalid input — it produces an Invalid Date:
const tanggalSalah = new Date("bukan-tanggal");
console.log(tanggalSalah); // Invalid Date
console.log(isNaN(tanggalSalah.getTime())); // true — the way to check for Invalid Date
// A helper function for safe parsing
function parseDate(input: string): Date | null {
const tanggal = new Date(input);
if (isNaN(tanggal.getTime())) {
return null; // Return null for invalid input
}
return tanggal;
}
const hasil = parseDate("2025-05-07");
if (hasil !== null) {
console.log(`Tanggal valid: ${hasil.toISOString()}`);
} else {
console.log("Format tanggal tidak valid");
}
Getters — Reading Date Components #
The Date object provides two groups of getters: ones working in local time and ones working in UTC:
const waktu = new Date("2025-05-07T15:30:45.123Z"); // UTC
// LOCAL time getters (depend on the system timezone)
console.log(waktu.getFullYear()); // Local year
console.log(waktu.getMonth()); // Local month (0-11)
console.log(waktu.getDate()); // Local day of month (1-31)
console.log(waktu.getDay()); // Local day of week (0=Sunday, 6=Saturday)
console.log(waktu.getHours()); // Local hour (0-23)
console.log(waktu.getMinutes()); // Local minutes
console.log(waktu.getSeconds()); // Local seconds
console.log(waktu.getMilliseconds()); // Local milliseconds
console.log(waktu.getTime()); // Unix timestamp in ms (always UTC)
console.log(waktu.getTimezoneOffset()); // Timezone offset in minutes
// UTC getters — more consistent across servers
console.log(waktu.getUTCFullYear()); // UTC year
console.log(waktu.getUTCMonth()); // UTC month (0-11)
console.log(waktu.getUTCDate()); // UTC day of month
console.log(waktu.getUTCHours()); // UTC hour
console.log(waktu.getUTCMinutes()); // UTC minutes
Getter Reference Table #
| Local Getter | UTC Getter | Value |
|---|---|---|
getFullYear() | getUTCFullYear() | Year (4 digits) |
getMonth() | getUTCMonth() | Month 0–11 |
getDate() | getUTCDate() | Day of month 1–31 |
getDay() | getUTCDay() | Day of week 0–6 |
getHours() | getUTCHours() | Hour 0–23 |
getMinutes() | getUTCMinutes() | Minutes 0–59 |
getSeconds() | getUTCSeconds() | Seconds 0–59 |
getTime() | — | Unix timestamp (ms) |
Setters — Changing Date Components #
Setters change the Date object in place (mutation). This is behavior to watch out for — Date is mutable:
const jadwalRapat = new Date("2025-05-07T09:00:00");
// Setters change the original object
jadwalRapat.setHours(14); // Move to 14:00
jadwalRapat.setMinutes(30); // Becomes 14:30
jadwalRapat.setFullYear(2025); // Year stays 2025
console.log(jadwalRapat.toISOString()); // "2025-05-07T07:30:00.000Z" (in UTC)
// ANTI-PATTERN: Modifying a Date used in many places
function tambahSatuHari(tanggal: Date): Date {
tanggal.setDate(tanggal.getDate() + 1); // ✗ Changes the original object!
return tanggal;
}
// CORRECT: Always create a new Date for calculation results
function tambahSatuHariAman(tanggal: Date): Date {
const hasilBaru = new Date(tanggal); // Make a copy
hasilBaru.setDate(hasilBaru.getDate() + 1);
return hasilBaru; // Return the new one — the original stays unchanged
}
const hari1 = new Date("2025-05-07");
const hari2 = tambahSatuHariAman(hari1);
console.log(hari1.toISOString()); // 2025-05-07 — unchanged
console.log(hari2.toISOString()); // 2025-05-08
Formatting with Intl.DateTimeFormat
#
Intl.DateTimeFormat is a modern API that produces locale-specific date formats — far more powerful than toLocaleString() for production needs:
const tanggal = new Date("2025-05-07T15:30:00+07:00");
// Full Indonesian format
const formatIndonesia = new Intl.DateTimeFormat("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "Asia/Jakarta",
});
console.log(formatIndonesia.format(tanggal));
// "Rabu, 7 Mei 2025 pukul 15.30.00"
// Date-only format
const formatTanggal = new Intl.DateTimeFormat("id-ID", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "Asia/Jakarta",
});
console.log(formatTanggal.format(tanggal)); // "07/05/2025"
// Time-only format
const formatWaktu = new Intl.DateTimeFormat("id-ID", {
hour: "2-digit",
minute: "2-digit",
timeZone: "Asia/Jakarta",
hour12: false,
});
console.log(formatWaktu.format(tanggal)); // "15.30"
// Relative format (when it happened) — Intl.RelativeTimeFormat
const relativeFormat = new Intl.RelativeTimeFormat("id-ID", { numeric: "auto" });
console.log(relativeFormat.format(-1, "day")); // "kemarin"
console.log(relativeFormat.format(1, "day")); // "besok"
console.log(relativeFormat.format(-3, "hour")); // "3 jam yang lalu"
console.log(relativeFormat.format(2, "week")); // "dalam 2 minggu"
Reusable Format Functions #
// Helper functions for consistent date formatting across the application
const TIMEZONE_WIB = "Asia/Jakarta";
const LOCALE_ID = "id-ID";
function formatTanggalIndonesia(tanggal: Date): string {
return new Intl.DateTimeFormat(LOCALE_ID, {
day: "numeric",
month: "long",
year: "numeric",
timeZone: TIMEZONE_WIB,
}).format(tanggal);
}
function formatWaktuSingkat(tanggal: Date): string {
return new Intl.DateTimeFormat(LOCALE_ID, {
hour: "2-digit",
minute: "2-digit",
timeZone: TIMEZONE_WIB,
hour12: false,
}).format(tanggal);
}
function formatISO(tanggal: Date): string {
return tanggal.toISOString(); // Always UTC, format: "2025-05-07T08:30:00.000Z"
}
const acara = new Date("2025-05-07T08:30:00Z");
console.log(formatTanggalIndonesia(acara)); // "7 Mei 2025"
console.log(formatWaktuSingkat(acara)); // "15.30" (WIB = UTC+7)
console.log(formatISO(acara)); // "2025-05-07T08:30:00.000Z"
Date Calculations #
Difference Between Two Dates #
const MILIDETIK_PER_DETIK = 1_000;
const MILIDETIK_PER_MENIT = 60 * MILIDETIK_PER_DETIK;
const MILIDETIK_PER_JAM = 60 * MILIDETIK_PER_MENIT;
const MILIDETIK_PER_HARI = 24 * MILIDETIK_PER_JAM;
function selisihHari(awal: Date, akhir: Date): number {
const selisihMs = akhir.getTime() - awal.getTime();
return Math.round(selisihMs / MILIDETIK_PER_HARI);
}
function selisihJam(awal: Date, akhir: Date): number {
const selisihMs = akhir.getTime() - awal.getTime();
return Math.round(selisihMs / MILIDETIK_PER_JAM);
}
function selisihMenit(awal: Date, akhir: Date): number {
const selisihMs = akhir.getTime() - awal.getTime();
return Math.round(selisihMs / MILIDETIK_PER_MENIT);
}
const mulai = new Date("2025-05-01T08:00:00Z");
const selesai = new Date("2025-05-07T18:00:00Z");
console.log(`Selisih: ${selisihHari(mulai, selesai)} hari`); // 6 days
console.log(`Selisih: ${selisihJam(mulai, selesai)} jam`); // 154 hours
console.log(`Selisih: ${selisihMenit(mulai, selesai)} menit`); // 9240 minutes
Adding and Subtracting Durations #
// Immutable helpers for Date manipulation
function tambahDetik(tanggal: Date, detik: number): Date {
return new Date(tanggal.getTime() + detik * MILIDETIK_PER_DETIK);
}
function tambahMenit(tanggal: Date, menit: number): Date {
return new Date(tanggal.getTime() + menit * MILIDETIK_PER_MENIT);
}
function tambahJam(tanggal: Date, jam: number): Date {
return new Date(tanggal.getTime() + jam * MILIDETIK_PER_JAM);
}
function tambahHari(tanggal: Date, hari: number): Date {
return new Date(tanggal.getTime() + hari * MILIDETIK_PER_HARI);
}
function tambahBulan(tanggal: Date, bulan: number): Date {
const hasil = new Date(tanggal);
hasil.setMonth(hasil.getMonth() + bulan);
return hasil;
}
function tambahTahun(tanggal: Date, tahun: number): Date {
const hasil = new Date(tanggal);
hasil.setFullYear(hasil.getFullYear() + tahun);
return hasil;
}
const sekarang = new Date("2025-05-07T12:00:00Z");
console.log(tambahJam(sekarang, 3).toISOString()); // 15:00 UTC
console.log(tambahHari(sekarang, 7).toISOString()); // May 14
console.log(tambahBulan(sekarang, 3).toISOString()); // August 2025
console.log(tambahTahun(sekarang, 1).toISOString()); // May 2026
Comparing Dates #
function sebelum(a: Date, b: Date): boolean {
return a.getTime() < b.getTime();
}
function sesudah(a: Date, b: Date): boolean {
return a.getTime() > b.getTime();
}
function samaPersis(a: Date, b: Date): boolean {
return a.getTime() === b.getTime();
}
function dalamRentang(tanggal: Date, awal: Date, akhir: Date): boolean {
return tanggal.getTime() >= awal.getTime() && tanggal.getTime() <= akhir.getTime();
}
const tgl1 = new Date("2025-05-07");
const tgl2 = new Date("2025-06-01");
const tgl3 = new Date("2025-05-15");
console.log(sebelum(tgl1, tgl2)); // true
console.log(dalamRentang(tgl3, tgl1, tgl2)); // true
// ANTI-PATTERN: Comparing Dates with the == or === operators
// console.log(tgl1 === new Date("2025-05-07")); // false! Different objects
// CORRECT: Use getTime() for comparison
Timezone Handling #
Timezone is one of the most complex parts of Date & Time. JavaScript Date objects always store time in UTC internally, but display it in the local timezone:
// Always use ISO 8601 with an explicit timezone offset to avoid ambiguity
const waktuWIB = new Date("2025-05-07T15:00:00+07:00"); // 15:00 WIB
const waktuUTC = new Date("2025-05-07T08:00:00Z"); // 08:00 UTC
// Both represent the exact same moment
console.log(waktuWIB.getTime() === waktuUTC.getTime()); // true
// How to display a time in a specific timezone
function formatDalamTimezone(tanggal: Date, timezone: string): string {
return new Intl.DateTimeFormat("id-ID", {
timeZone: timezone,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).format(tanggal);
}
const momenSama = new Date("2025-05-07T08:00:00Z");
console.log(formatDalamTimezone(momenSama, "Asia/Jakarta")); // WIB (UTC+7)
console.log(formatDalamTimezone(momenSama, "Asia/Makassar")); // WITA (UTC+8)
console.log(formatDalamTimezone(momenSama, "Asia/Jayapura")); // WIT (UTC+9)
console.log(formatDalamTimezone(momenSama, "UTC")); // UTC
Avoid creatingDateobjects from strings without an explicit timezone for important dates.new Date("2025-05-07")is interpreted as UTC midnight, butnew Date("2025-05-07T00:00:00")without an offset is interpreted as local time — different behavior that often causes off-by-one-day bugs, especially for applications running on servers with a different timezone than their users.
Branded Types for Date Type Safety #
TypeScript doesn’t distinguish between a Date representing a local date, UTC, or a timestamp. Branded types can help prevent accidental mixing:
// Branded types for various time representations
type ISODateString = string & { readonly __brand: "ISODateString" };
type UnixTimestampMs = number & { readonly __brand: "UnixTimestampMs" };
type LocaleDateString = string & { readonly __brand: "LocaleDateString" };
// Constructor functions
function toISODateString(tanggal: Date): ISODateString {
return tanggal.toISOString() as ISODateString;
}
function toUnixTimestamp(tanggal: Date): UnixTimestampMs {
return tanggal.getTime() as UnixTimestampMs;
}
function fromUnixTimestamp(ts: UnixTimestampMs): Date {
return new Date(ts);
}
// Usage — TypeScript prevents mixing types
const ts = toUnixTimestamp(new Date()); // UnixTimestampMs
const iso = toISODateString(new Date()); // ISODateString
// A function that only accepts ISO strings
function simpanKeDatabase(iso: ISODateString): void {
console.log(`Menyimpan: ${iso}`);
}
simpanKeDatabase(iso); // ✓ ISODateString
// simpanKeDatabase(ts.toString()); // ✗ plain strings not accepted
// simpanKeDatabase("2025-05-07"); // ✗ plain strings not accepted
Serialization and Deserialization #
// Serializing a Date to JSON — automatically uses ISO 8601 UTC
const data = {
nama: "Budi",
dibuatPada: new Date("2025-05-07T08:00:00Z"),
};
const json = JSON.stringify(data);
// '{"nama":"Budi","dibuatPada":"2025-05-07T08:00:00.000Z"}'
// Deserialization — JSON.parse does NOT automatically convert strings to Dates
const parsed = JSON.parse(json);
console.log(typeof parsed.dibuatPada); // "string" — not a Date!
// parsed.dibuatPada.getFullYear(); // ✗ Runtime error — strings don't have getFullYear
// CORRECT: Explicit conversion after parsing
const dipulihkan: Date = new Date(parsed.dibuatPada);
console.log(dipulihkan.getFullYear()); // 2025 ✓
// With a proper interface for API data
interface ResponseAPI {
nama: string;
dibuatPada: string; // String, not Date — because JSON doesn't know about Dates
}
function prosesResponse(raw: ResponseAPI): { nama: string; dibuatPada: Date } {
return {
nama: raw.nama,
dibuatPada: new Date(raw.dibuatPada), // Explicit conversion
};
}
When to Switch to Third-Party Libraries #
The built-in Date object is enough for basic needs. Consider libraries for more complex requirements:
// date-fns — a functional, tree-shakeable, very popular library
import { format, addDays, differenceInDays, isAfter, parseISO } from "date-fns";
import { id } from "date-fns/locale"; // Indonesian locale
const sekarang = new Date();
const besok = addDays(sekarang, 1);
console.log(format(sekarang, "EEEE, d MMMM yyyy", { locale: id }));
// "Rabu, 7 Mei 2025"
console.log(differenceInDays(besok, sekarang)); // 1
console.log(isAfter(besok, sekarang)); // true
// Temporal API (TC39 proposal — the future of JavaScript)
// Currently available via the @js-temporal/polyfill
// import { Temporal } from "@js-temporal/polyfill";
// const sekarang = Temporal.Now.plainDateTimeISO();
// const besok = sekarang.add({ days: 1 });
// — Immutable, timezone-aware, far better than Date
Comparison Table #
Use the built-in Date if:
✓ Simple operations (create, format, basic differences)
✓ No complex timezone needs
✓ You want zero dependencies
✓ Bundle size is sensitive
Consider date-fns if:
✓ Many date manipulation operations
✓ Need rich locale formatting
✓ The team is already familiar with functional APIs
✓ Tree-shaking matters for bundle size
Consider the Temporal API (polyfill) if:
✓ Need accurate timezone-aware computation
✓ Complex calendar operations
✓ Immutability is a priority
✓ Ready to depend on an experimental API
Summary #
- Zero-based months are the most common trap —
new Date(2025, 4, 7)is May 7 (not April); use the ISO stringnew Date("2025-05-07")to avoid ambiguity entirely.- Validate
DatewithisNaN(tanggal.getTime())— the constructor doesn’t throw for invalid input, it produces anInvalid Date; always validate after parsing from external strings.Dateis mutable — setters change the original object; always make a copy withnew Date(tanggal)before modifying if the original value is still needed.- Use
getTime()for comparisons — the==and===operators compare object references, not time values;a.getTime() === b.getTime()is the correct way.- Always store and transmit time in UTC (ISO 8601) — use
toISOString()for serialization; useIntl.DateTimeFormatwith thetimeZoneoption for user-facing display.- Avoid strings without an explicit timezone for important dates —
new Date("2025-05-07")is UTC midnight, butnew Date("2025-05-07T00:00:00")is local midnight; the different behavior can cause off-by-one-day bugs.Intl.DateTimeFormatis stronger thantoLocaleString()for production — it supports explicit timezone specification and is more consistent across browsers and Node.js.Intl.RelativeTimeFormatfor relative time like “kemarin”, “3 jam yang lalu” — no extra library needed for these cases.- Branded types like
ISODateStringandUnixTimestampMshelp prevent mixing different time representations at the type-system level.- Consider
date-fnsfor complex date manipulation — the library is tree-shakeable, functional, and supports the Indonesian locale well.