ation contracts, and implementing immutable operations. The following architecture demonstrates a robust approach using TypeScript.
Step 1: Define a Temporal Contract
Never pass raw Date objects across API boundaries or state management layers. Instead, serialize to ISO 8601 strings and deserialize through a controlled adapter.
export type TemporalISO = string;
export interface TemporalAdapter {
now(): TemporalISO;
parse(input: string): TemporalISO;
format(iso: TemporalISO, pattern: string): string;
add(iso: TemporalISO, amount: number, unit: 'day' | 'month' | 'year'): TemporalISO;
diff(isoA: TemporalISO, isoB: TemporalISO, unit: 'day' | 'hour' | 'minute'): number;
isWithinRange(iso: TemporalISO, start: TemporalISO, end: TemporalISO): boolean;
}
Step 2: Implement the Adapter (Day.js Example)
This implementation wraps Day.js while enforcing immutability and explicit timezone handling.
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
export class DayjsAdapter implements TemporalAdapter {
private readonly defaultTZ: string;
constructor(config: { defaultTimezone?: string }) {
this.defaultTZ = config.defaultTimezone ?? 'UTC';
}
now(): TemporalISO {
return dayjs().tz(this.defaultTZ).toISOString();
}
parse(input: string): TemporalISO {
const parsed = dayjs(input);
if (!parsed.isValid()) {
throw new Error(`Invalid temporal input: ${input}`);
}
return parsed.toISOString();
}
format(iso: TemporalISO, pattern: string): string {
return dayjs(iso).tz(this.defaultTZ).format(pattern);
}
add(iso: TemporalISO, amount: number, unit: 'day' | 'month' | 'year'): TemporalISO {
return dayjs(iso).add(amount, unit).toISOString();
}
diff(isoA: TemporalISO, isoB: TemporalISO, unit: 'day' | 'hour' | 'minute'): number {
return dayjs(isoA).diff(dayjs(isoB), unit);
}
isWithinRange(iso: TemporalISO, start: TemporalISO, end: TemporalISO): boolean {
const target = dayjs(iso);
return target.isAfter(dayjs(start)) && target.isBefore(dayjs(end));
}
}
Step 3: Implement the Adapter (date-fns Example)
For projects prioritizing tree-shaking and functional purity, date-fns provides a composable alternative.
import {
format as formatDate,
addDays,
addMonths,
addYears,
differenceInDays,
differenceInHours,
differenceInMinutes,
isAfter,
isBefore,
parseISO,
isValid
} from 'date-fns';
export class DateFnsAdapter implements TemporalAdapter {
private readonly locale: Locale;
constructor(config: { locale?: Locale }) {
this.locale = config.locale ?? enUS;
}
now(): TemporalISO {
return new Date().toISOString();
}
parse(input: string): TemporalISO {
const parsed = parseISO(input);
if (!isValid(parsed)) {
throw new Error(`Invalid temporal input: ${input}`);
}
return parsed.toISOString();
}
format(iso: TemporalISO, pattern: string): string {
return formatDate(parseISO(iso), pattern, { locale: this.locale });
}
add(iso: TemporalISO, amount: number, unit: 'day' | 'month' | 'year'): TemporalISO {
const base = parseISO(iso);
const next = unit === 'day' ? addDays(base, amount)
: unit === 'month' ? addMonths(base, amount)
: addYears(base, amount);
return next.toISOString();
}
diff(isoA: TemporalISO, isoB: TemporalISO, unit: 'day' | 'hour' | 'minute'): number {
const a = parseISO(isoA);
const b = parseISO(isoB);
if (unit === 'day') return differenceInDays(a, b);
if (unit === 'hour') return differenceInHours(a, b);
return differenceInMinutes(a, b);
}
isWithinRange(iso: TemporalISO, start: TemporalISO, end: TemporalISO): boolean {
const target = parseISO(iso);
return isAfter(target, parseISO(start)) && isBefore(target, parseISO(end));
}
}
Architecture Decisions & Rationale
- ISO 8601 as Transport Standard: All internal and external temporal data uses
YYYY-MM-DDTHH:mm:ss.sssZ. This eliminates locale-dependent parsing ambiguity and ensures consistent serialization across JSON payloads.
- Adapter Pattern: Wrapping the library behind a consistent interface allows swapping implementations without refactoring business logic. It also simplifies unit testing by enabling mock adapters.
- Explicit Timezone Resolution: The adapter accepts a
defaultTimezone configuration. This prevents implicit host-timezone leakage and ensures deterministic formatting across environments.
- Immutable Operations: Both implementations return new ISO strings rather than mutating inputs. This aligns with modern state management practices and prevents shared-reference bugs.
Pitfall Guide
1. The Zero-Indexed Month Trap
Explanation: Native Date months run from 0 (January) to 11 (December). Developers frequently pass 1 expecting February, resulting in off-by-one errors that silently corrupt scheduling logic.
Fix: Never use native month indices in business logic. Always parse from ISO strings or use library methods that accept 1-indexed months or named constants.
2. Implicit Local Timezone Conversion
Explanation: new Date('2026-05-16') interprets the string as midnight in the host timezone. When deployed to a server in a different region, the same code produces a different UTC timestamp.
Fix: Always append timezone designators (Z for UTC, or +00:00) or use explicit parsing functions that enforce a specific timezone context.
3. String Parsing Ambiguity
Explanation: Formats like 05/16/2026 or 16-05-2026 are interpreted differently across locales. The runtime may treat them as MM/DD/YYYY or DD/MM/YYYY, causing silent data corruption.
Fix: Enforce ISO 8601 at the API boundary. Use customParseFormat plugins or explicit validation schemas to reject non-standard inputs before they reach business logic.
4. Mutating Temporal Objects
Explanation: Native Date methods like setDate() or setMonth() modify the instance in place. Passing a Date object to a utility function that mutates it will corrupt the original reference.
Fix: Treat all temporal values as immutable. Use library methods that return new instances, or clone before modification. Prefer ISO strings over Date objects in state stores.
5. Ignoring Daylight Saving Time Boundaries
Explanation: Calculating intervals by assuming 24 hours per day fails during DST transitions. A "day" may be 23 or 25 hours depending on the region and date.
Fix: Use calendar-aware arithmetic (addDays, addMonths) rather than millisecond multiplication. Libraries handle DST transitions internally when using named units.
6. Relying on toString() for Serialization
Explanation: Date.prototype.toString() outputs a locale-dependent, timezone-aware string that varies across environments. It is unsuitable for APIs, databases, or logging.
Fix: Always use toISOString() for serialization. Reserve toString() or toLocaleString() exclusively for UI rendering.
7. Hardcoding Timezone Offsets
Explanation: Storing fixed offsets like +05:30 ignores historical and future DST rule changes. Offsets shift over time, making hardcoded values obsolete.
Fix: Store IANA timezone identifiers (America/New_York, Europe/London) alongside temporal data. Resolve offsets at runtime using the Intl API or library timezone plugins.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Micro-frontend architecture | date-fns | Tree-shaking reduces per-bundle overhead; functional style aligns with isolated component boundaries | Low (selective imports keep payload minimal) |
| Enterprise scheduling platform | Day.js + timezone plugin | Built-in IANA timezone resolution handles global DST transitions without external data files | Medium (2KB base + timezone data loaded on demand) |
| CLI / Node-only tooling | Native Date + Intl | No runtime dependency required; server environments have consistent timezone configurations | Zero (built-in APIs only) |
| Real-time dashboard with live updates | Day.js | Chainable API simplifies interval calculations; relative time plugins reduce UI formatting code | Low (minimal bundle impact) |
| Financial / audit logging | date-fns | Pure functions guarantee deterministic output; strong TypeScript support prevents temporal type errors | Low (tree-shakable, predictable behavior) |
Configuration Template
// src/core/temporal/config.ts
import { DayjsAdapter } from './adapters/dayjs-adapter';
import { DateFnsAdapter } from './adapters/date-fns-adapter';
import type { TemporalAdapter } from './types';
export type TemporalEngine = 'dayjs' | 'date-fns';
export interface TemporalConfig {
engine: TemporalEngine;
defaultTimezone: string;
locale?: string;
}
export function createTemporalEngine(config: TemporalConfig): TemporalAdapter {
switch (config.engine) {
case 'dayjs':
return new DayjsAdapter({ defaultTimezone: config.defaultTimezone });
case 'date-fns':
return new DateFnsAdapter({ locale: config.locale });
default:
throw new Error(`Unsupported temporal engine: ${config.engine}`);
}
}
// Usage in application bootstrap
const temporal = createTemporalEngine({
engine: 'dayjs',
defaultTimezone: 'UTC',
});
export { temporal };
Quick Start Guide
- Install dependencies: Run
npm install dayjs date-fns (or select one based on your decision matrix).
- Create the adapter layer: Copy the
TemporalAdapter interface and your chosen implementation into src/core/temporal/adapters/.
- Initialize the engine: Import
createTemporalEngine in your application entry point and configure the default timezone.
- Replace native usage: Search for
new Date(, Date.now(), and toISOString() calls. Replace them with temporal.now(), temporal.parse(), and temporal.format().
- Validate: Run a quick integration test that parses an ISO string, adds 7 days, and formats the result. Verify the output matches expectations across different host timezones.