Skip to main content

TypeScript Date Handling Guide

You've probably run into frustrating date bugs—timestamps showing in the wrong timezone, dates formatted inconsistently across browsers, or mysterious off-by-one errors. TypeScript's Date object inherits JavaScript's quirks, and while it works for simple cases, production apps need more robust solutions. In this guide, you'll learn practical patterns for formatting dates with Intl.DateTimeFormat, validating date inputs with type guards, handling timezones correctly, and when to reach for libraries like date-fns. We'll also cover the common pitfalls that trip up even experienced developers.

Formatting Dates with Intl.DateTimeFormat

The modern way to format dates in TypeScript is Intl.DateTimeFormat. It handles localization automatically, supports all timezones, and gives you fine-grained control over date formatting without manual string manipulation.

const apiTimestamp = new Date('2024-09-16T14:30:00.000Z');

// Format for US users
const usFormatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});

console.log(usFormatter.format(apiTimestamp));
// Output: "September 16, 2024 at 2:30 PM"

// Format for European users
const euFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short'
});

console.log(euFormatter.format(apiTimestamp));
// Output: "Montag, 16. September 2024 um 14:30"

You can create a reusable formatting function with TypeScript types:

type DateFormatOptions = {
locale?: string;
style?: 'short' | 'medium' | 'long' | 'full';
};

function formatUserFriendlyDate(
date: Date,
options: DateFormatOptions = {}
): string {
const { locale = 'en-US', style = 'medium' } = options;

return new Intl.DateTimeFormat(locale, {
dateStyle: style,
timeStyle: style
}).format(date);
}

const eventDate = new Date('2024-09-16T14:30:00.000Z');
console.log(formatUserFriendlyDate(eventDate, { style: 'full' }));
// Output: "Monday, September 16, 2024 at 2:30:00 PM Coordinated Universal Time"

When working with Convex, Intl.DateTimeFormat ensures consistent date displays across different user locales.

Custom Date String Formatting

When you need a specific format that Intl.DateTimeFormat doesn't provide (like ISO-style formats for APIs), you can build custom strings using Date methods. Just remember that months are zero-indexed—a common source of bugs.

function formatAsISO(date: Date): string {
const year = date.getFullYear();
// Month is zero-indexed, so add 1
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}

const appointmentDate = new Date('2024-09-16');
console.log(formatAsISO(appointmentDate)); // Output: "2024-09-16"

For API requests that need timestamps with time components:

function formatForApiRequest(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');

return `${year}-${month}-${day}T${hours}:${minutes}:00`;
}

const eventTime = new Date();
console.log(formatForApiRequest(eventTime)); // Output: "2024-09-16T14:30:00"

For applications requiring complex filtering, these standardized date strings make date-based queries and comparisons more reliable.

Calculating Date Differences

Want to show "Trial ends in 7 days" or calculate subscription durations? You'll need to find the difference between two dates. The core approach is subtracting timestamps (milliseconds since Unix epoch).

function getDaysBetween(startDate: Date, endDate: Date): number {
const msPerDay = 1000 * 60 * 60 * 24;
const diffMs = endDate.getTime() - startDate.getTime();

// Round to handle daylight saving time edge cases
return Math.round(diffMs / msPerDay);
}

const trialStart = new Date('2024-09-16');
const trialEnd = new Date('2024-09-30');
console.log(getDaysBetween(trialStart, trialEnd)); // Output: 14

For more complex use cases like business days or handling different units:

type TimeUnit = 'days' | 'hours' | 'minutes' | 'seconds';

function getTimeDifference(
startDate: Date,
endDate: Date,
unit: TimeUnit
): number {
const diffMs = Math.abs(endDate.getTime() - startDate.getTime());

const conversions = {
days: 1000 * 60 * 60 * 24,
hours: 1000 * 60 * 60,
minutes: 1000 * 60,
seconds: 1000
};

return Math.floor(diffMs / conversions[unit]);
}

const eventStart = new Date('2024-09-16T09:00:00');
const eventEnd = new Date('2024-09-16T17:30:00');

console.log(getTimeDifference(eventStart, eventEnd, 'hours')); // Output: 8
console.log(getTimeDifference(eventStart, eventEnd, 'minutes')); // Output: 510

When building applications with TypeScript on Convex, these functions help calculate subscription periods, trial countdowns, and event durations reliably.

Creating Dates from Timestamps

APIs often return timestamps (milliseconds since January 1, 1970 UTC). Converting them to Date objects is straightforward, but you need to handle edge cases.

// Typical API response with Unix timestamp
const apiResponse = {
createdAt: 1694913600000,
userId: 'usr_123'
};

const createdDate = new Date(apiResponse.createdAt);
console.log(createdDate.toISOString()); // Output: "2023-09-17T02:00:00.000Z"

When timestamps arrive as strings, convert string to number first:

function parseDateFromTimestamp(timestamp: string | number): Date {
const ms = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp;

// Validate the timestamp
if (isNaN(ms) || ms < 0) {
throw new Error(`Invalid timestamp: ${timestamp}`);
}

return new Date(ms);
}

const dateFromString = parseDateFromTimestamp("1694913600000");
const dateFromNumber = parseDateFromTimestamp(1694913600000);

console.log(dateFromString.toISOString());
// Output: "2023-09-17T02:00:00.000Z"

This pattern is essential when processing timestamps from server responses or database queries in your TypeScript applications.

Adding or Subtracting Days

Need to calculate "30 days from now" for a trial period or "7 days ago" for analytics? The key is creating a new Date object instead of mutating the original (more on this in Common Pitfalls below).

function addDays(date: Date, days: number): Date {
const result = new Date(date); // Create a copy to avoid mutation
result.setDate(result.getDate() + days);
return result;
}

const subscriptionStart = new Date('2024-09-16');
const subscriptionEnd = addDays(subscriptionStart, 30);

console.log(subscriptionStart.toISOString()); // Original unchanged: "2024-09-16T00:00:00.000Z"
console.log(subscriptionEnd.toISOString()); // Output: "2024-10-16T00:00:00.000Z"

You can use negative values to go backwards:

function getDateRange(endDate: Date, daysBack: number) {
return {
start: addDays(endDate, -daysBack),
end: endDate
};
}

const today = new Date('2024-09-16');
const last7Days = getDateRange(today, 7);

console.log(last7Days.start.toISOString()); // Output: "2024-09-09T00:00:00.000Z"
console.log(last7Days.end.toISOString()); // Output: "2024-09-16T00:00:00.000Z"

Why setDate() instead of manual millisecond math? It automatically handles month rollovers and leap years. Try addDays(new Date('2024-01-31'), 1)—it correctly returns February 1st, not January 32nd.

When working with complex data operations in TypeScript, these helper functions simplify date-based filtering and range queries.

Handling Timezones Correctly

Here's the truth about timezones: Date objects always store time in UTC internally, but display in your local timezone by default. This trips up developers constantly. For global apps, use Intl.DateTimeFormat with IANA timezone names.

const serverTimestamp = new Date('2024-09-16T14:30:00.000Z');

// Display the same moment in different timezones
const timezones = ['America/New_York', 'Europe/London', 'Asia/Tokyo'];

timezones.forEach(tz => {
const formatted = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
dateStyle: 'short',
timeStyle: 'long'
}).format(serverTimestamp);

console.log(`${tz}: ${formatted}`);
});

// Output:
// America/New_York: 9/16/24, 10:30:00 AM EDT
// Europe/London: 16/09/2024, 15:30:00 BST
// Asia/Tokyo: 9/16/24, 11:30:00 PM GMT+9

For user-facing timestamps, always format in the user's timezone:

function formatInUserTimezone(date: Date, userTimezone: string): string {
return new Intl.DateTimeFormat('en-US', {
timeZone: userTimezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
}).format(date);
}

const meetingTime = new Date('2024-09-16T18:00:00.000Z');
const userInParis = formatInUserTimezone(meetingTime, 'Europe/Paris');

console.log(userInParis);
// Output: "Sep 16, 2024, 08:00 PM CEST"

When to Use Manual Offset Calculations vs. Intl API

ApproachUse WhenAvoid When
Intl.DateTimeFormatDisplaying dates to users, handling named timezones, internationalizationHigh-frequency operations (format dates in a loop of 10,000+ items)
Manual offset (getTimezoneOffset())Calculating time differences, converting to UTC for storageDisplaying to users, handling DST transitions

For TypeScript applications with global users, Intl.DateTimeFormat with IANA timezone identifiers ensures accurate, DST-aware date handling across all regions.

Comparing Dates

Here's a gotcha: you can't use === to compare Date objects because they're objects, not primitives. Even if two dates represent the same moment, date1 === date2 is always false. Compare timestamps instead.

function areDatesEqual(date1: Date, date2: Date): boolean {
return date1.getTime() === date2.getTime();
}

const userLoginTime1 = new Date('2024-09-16T14:30:00.000Z');
const userLoginTime2 = new Date('2024-09-16T14:30:00.000Z');

console.log(userLoginTime1 === userLoginTime2); // false (different objects)
console.log(areDatesEqual(userLoginTime1, userLoginTime2)); // true (same timestamp)

For sorting or determining chronological order, you can subtract dates directly or compare timestamps:

function compareDates(date1: Date, date2: Date): number {
// Returns negative if date1 is earlier, 0 if equal, positive if date1 is later
return date1.getTime() - date2.getTime();
}

const events = [
{ name: 'Product Launch', date: new Date('2024-10-01') },
{ name: 'Team Meeting', date: new Date('2024-09-20') },
{ name: 'Q4 Review', date: new Date('2024-12-15') }
];

// Sort events chronologically
events.sort((a, b) => compareDates(a.date, b.date));

console.log(events.map(e => e.name));
// Output: ["Team Meeting", "Product Launch", "Q4 Review"]

This comparison pattern is essential in complex filtering scenarios where you need to sort or filter arrays of timestamped data.

Validating Dates with Type Guards

Invalid dates are sneaky—new Date('invalid') doesn't throw an error. It silently creates an "Invalid Date" object that breaks your app later. Use type guards to catch these at runtime.

function isValidDate(date: unknown): date is Date {
return date instanceof Date && !isNaN(date.getTime());
}

// Handling user input or API responses
const userInput = "2024-13-45"; // Invalid: month 13, day 45
const attemptedDate = new Date(userInput);

if (isValidDate(attemptedDate)) {
console.log("Valid date:", attemptedDate.toISOString());
} else {
console.error("Invalid date input");
}
// Output: "Invalid date input"

For stricter validation with specific date string formats:

// Type for ISO date strings like "2024-09-16"
type ISODateString = `${number}-${number}-${number}`;

function isISODateString(value: string): value is ISODateString {
const isoPattern = /^\d{4}-\d{2}-\d{2}$/;
if (!isoPattern.test(value)) return false;

// Check if it's actually a valid date
const date = new Date(value);
return isValidDate(date);
}

function parseISODate(dateString: string): Date {
if (!isISODateString(dateString)) {
throw new Error(`Invalid ISO date format: ${dateString}`);
}

return new Date(dateString);
}

// Safe parsing with type narrowing
try {
const apiDate = parseISODate("2024-09-16");
console.log(apiDate.toISOString()); // Output: "2024-09-16T00:00:00.000Z"
} catch (error) {
console.error(error);
}

These type guards are crucial when processing dates from API responses or user input:

interface ApiResponse {
createdAt: string;
updatedAt: string;
}

function processApiResponse(response: ApiResponse) {
const createdDate = new Date(response.createdAt);
const updatedDate = new Date(response.updatedAt);

// Validate before using
if (!isValidDate(createdDate) || !isValidDate(updatedDate)) {
throw new Error("API returned invalid date format");
}

// Now TypeScript knows these are valid Date objects
return {
created: createdDate.toISOString(),
updated: updatedDate.toISOString()
};
}

Working with date-fns for Complex Operations

The native Date object handles basics, but for production apps with complex date logic, date-fns offers a cleaner API. It's fully typed for TypeScript and tree-shakeable, so you only bundle what you use.

npm install date-fns

Here's why developers prefer it:

import { format, addDays, differenceInDays, isBefore, parseISO } from 'date-fns';

// Formatting is more readable than Intl or manual string building
const eventDate = new Date('2024-09-16T14:30:00');
console.log(format(eventDate, 'PPpp'));
// Output: "Sep 16, 2024 at 2:30 PM"

console.log(format(eventDate, 'yyyy-MM-dd HH:mm'));
// Output: "2024-09-16 14:30"

// Date arithmetic is clearer
const trialStart = new Date('2024-09-16');
const trialEnd = addDays(trialStart, 14);
console.log(format(trialEnd, 'PP'));
// Output: "Sep 30, 2024"

// Comparisons read like English
const deadline = new Date('2024-12-31');
const today = new Date();

if (isBefore(today, deadline)) {
const daysLeft = differenceInDays(deadline, today);
console.log(`${daysLeft} days until deadline`);
}

date-fns excels at handling edge cases native Date struggles with:

import { addMonths, endOfMonth, startOfMonth } from 'date-fns';

// Adding months handles varying month lengths correctly
const jan31 = new Date('2024-01-31');
const oneMonthLater = addMonths(jan31, 1);
console.log(format(oneMonthLater, 'PP'));
// Output: "Feb 29, 2024" (leap year, goes to last day of Feb)

// Get month boundaries for analytics queries
const reportMonth = new Date('2024-09-16');
const monthStart = startOfMonth(reportMonth);
const monthEnd = endOfMonth(reportMonth);

console.log(format(monthStart, 'PP')); // Output: "Sep 1, 2024"
console.log(format(monthEnd, 'PP')); // Output: "Sep 30, 2024"

When to Use date-fns vs. Native Date

Use date-fnsUse Native Date
Complex date arithmetic (quarters, business days, durations)Simple timestamp operations
Parsing varied date formatsWorking with ISO strings or timestamps
Frequent formatting with custom patternsBasic toISOString() or Intl.DateTimeFormat
Need immutability guaranteesPerformance-critical tight loops

Common Pitfalls When Working with Dates

1. Zero-Indexed Months

This catches everyone at least once. Months start at 0 (January) but days start at 1.

// WRONG: This creates February 5, not January 5
const wrong = new Date(2024, 1, 5);
console.log(wrong.toISOString()); // Output: "2024-02-05T..."

// CORRECT: January is month 0
const correct = new Date(2024, 0, 5);
console.log(correct.toISOString()); // Output: "2024-01-05T..."

2. Date Objects Are Mutable

Mutating dates causes hard-to-track bugs when functions modify arguments unexpectedly.

function addOneDay(date: Date): Date {
date.setDate(date.getDate() + 1); // Mutates the original!
return date;
}

const appointment = new Date('2024-09-16');
const nextDay = addOneDay(appointment);

console.log(appointment.toISOString()); // CHANGED: "2024-09-17T..."
console.log(nextDay.toISOString()); // "2024-09-17T..."

Always create copies:

function addOneDaySafely(date: Date): Date {
const copy = new Date(date); // Create a new instance
copy.setDate(copy.getDate() + 1);
return copy;
}

const appointment = new Date('2024-09-16');
const nextDay = addOneDaySafely(appointment);

console.log(appointment.toISOString()); // UNCHANGED: "2024-09-16T..."
console.log(nextDay.toISOString()); // "2024-09-17T..."

3. Invalid Dates Don't Throw Errors

JavaScript's Date constructor is overly permissive. Invalid input creates an "Invalid Date" object instead of throwing an error.

const bad = new Date('not a date');
console.log(bad); // Output: Invalid Date
console.log(typeof bad); // Output: "object" (not helpful!)
console.log(bad.getTime()); // Output: NaN

// This compiles but crashes at runtime
// bad.toISOString(); // Throws: "RangeError: Invalid time value"

Always validate (see Type Guards section above):

function safeCreateDate(input: string): Date {
const date = new Date(input);
if (isNaN(date.getTime())) {
throw new Error(`Cannot create date from: ${input}`);
}
return date;
}

4. Local Time vs. UTC Confusion

new Date() creates dates in local time, but toISOString() always outputs UTC. This causes "off by one day" bugs.

// Your computer is in PST (UTC-8)
const localDate = new Date('2024-09-16'); // Parsed as local midnight
console.log(localDate.toISOString()); // Output: "2024-09-16T07:00:00.000Z" (UTC)

// Explicit UTC to avoid surprises
const utcDate = new Date('2024-09-16T00:00:00.000Z');
console.log(utcDate.toISOString()); // Output: "2024-09-16T00:00:00.000Z"

Final Thoughts on TypeScript Date Handling

You've learned the practical patterns for handling dates in TypeScript: format with Intl.DateTimeFormat for user-facing output, validate with type guards to catch bad input early, handle timezones using IANA identifiers, and reach for date-fns when date logic gets complex. The native Date object is quirky, but with these patterns and awareness of common pitfalls, you can build reliable date handling into your applications.