/**
* Technical Architecture & Design Notes:
*
* Parsing arbitrary date strings into ISO-8601 (YYYY-MM-DD) format in TypeScript requires strict
* compile-time type safety alongside runtime verification:
* 1. `Date.parse()` and `new Date(string)` rely on engine-dependent heuristics:
* - Dotted dates like `2024.02.09` are rejected or parsed incorrectly across platforms.
* - Slash formats like `05/01/2024` default to US (MM/DD/YYYY) in ECMAScript standard environments.
* 2. Silent Date Rollover: JavaScript runtime `Date` constructors automatically shift invalid calendar dates
* (e.g., `new Date("Feb 30, 2024")` rolls over to March 1st or 2nd instead of failing).
*
* Strategy Implemented:
* - Pre-processing Sanitization:
* Removes non-printable Unicode control characters (such as Zero-Width Space \u200B),
* enclosing double/single quotes, and extraneous whitespaces.
* - Layered Parsing Architecture:
* Pass 1: Structured Pattern Matching (Regex).
* Captures standard numerical patterns (MM/DD/YYYY, MM-DD-YYYY, YYYY.MM.DD, YYYY/MM/DD, YYYY-MM-DD)
* and extracts component tokens directly to eliminate native parser ambiguity.
* Pass 2: Engine Fallback via Native Date Parsing.
* Handles textual formats (e.g., "January 5, 2024", "25 Dec 2025", "Jan 7, 2025").
* - Strict Calendar Validation:
* Validates that constructed month and day values match original inputs exactly.
* Prevents date silent overflow bugs (e.g., treating February 30th or April 31st as valid dates).
*
* Performance & Memory Notes:
* - Time Complexity: O(1) per date string evaluation. Regex evaluation and string manipulation execution time is bounded.
* - Space Complexity: O(1) auxiliary memory overhead per date string conversion call.
*
* Security Concerns:
* - Regex expressions use strict anchored boundaries (^ ... $) avoiding ReDoS vulnerabilities.
* - Non-string input safety checks and TypeScript type assertions prevent runtime exceptions.
*
* Error Handling:
* - Invalid inputs gracefully return `null`, allowing callers to handle unparseable or logically impossible dates.
*/
/** Type-safe interface representing extracted numeric date components */
interface DateComponents {
year: number;
month: number;
day: number;
}
/** ISO-8601 string template type constraint */
type IsoDateString = `${number}-${string}-${string}`;
class DateNormalizer {
/**
* Readonly month lookup map converting month names to 0-indexed integers.
*/
private static readonly MONTH_MAP: Readonly<Record<string, number>> = Object.freeze({
jan: 0, january: 0,
feb: 1, february: 1,
mar: 2, march: 2,
apr: 3, april: 3,
may: 4,
jun: 5, june: 5,
jul: 6, july: 6,
aug: 7, august: 7,
sep: 8, september: 8,
oct: 9, october: 9,
nov: 10, november: 10,
dec: 11, december: 11,
});
/**
* Converts supported arbitrary date strings into ISO-8601 standard format (YYYY-MM-DD).
*
* @param rawInput Raw uncleaned date input string.
* @returns Normalized ISO-8601 date string, or null if invalid or unsupported.
*/
public static normalizeToIso(rawInput: unknown): IsoDateString | null {
if (typeof rawInput !== 'string') {
return null;
}
// 1. Sanitize input string
const cleanInput: string = this.sanitizeInput(rawInput);
if (!cleanInput) {
return null;
}
// 2. Pass 1: Handle structured numerical formats via explicit pattern extraction
const numericParsed: IsoDateString | null = this.parseNumericPatterns(cleanInput);
if (numericParsed) {
return numericParsed;
}
// 3. Pass 2: Fallback to engine parsing for textual/named month strings
const textualParsed: IsoDateString | null = this.parseTextualPatterns(cleanInput);
if (textualParsed) {
return textualParsed;
}
return null;
}
/**
* Sanitizes input string by stripping quotes, non-printable control characters, and leading/trailing spaces.
*/
private static sanitizeInput(input: string): string {
return input
.replace(/[\u200B-\u200D\uFEFF]/g, '') // Remove hidden Unicode zero-width/control characters
.replace(/^["']|["']$/g, '') // Trim outer quotes
.trim();
}
/**
* Evaluates structured numeric formats directly using Regex patterns.
*/
private static parseNumericPatterns(input: string): IsoDateString | null {
// Pattern 1: ISO & standard Year-First variants (YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD)
const yearFirstMatch: RegExpMatchArray | null = input.match(
/^(\d{4})[./-](0?[1-9]|1[0-2])[./-](0?[1-9]|[12]\d|3[01])$/
);
if (yearFirstMatch) {
const year: number = parseInt(yearFirstMatch[1], 10);
const month: number = parseInt(yearFirstMatch[2], 10);
const day: number = parseInt(yearFirstMatch[3], 10);
return this.formatIfValid({ year, month, day });
}
// Pattern 2: US Standard Slash/Dash Month-First variants (MM/DD/YYYY, MM-DD-YYYY)
const monthFirstMatch: RegExpMatchArray | null = input.match(
/^(0?[1-9]|1[0-2])[-/](0?[1-9]|[12]\d|3[01])[-/](\d{4})$/
);
if (monthFirstMatch) {
const month: number = parseInt(monthFirstMatch[1], 10);
const day: number = parseInt(monthFirstMatch[2], 10);
const year: number = parseInt(monthFirstMatch[3], 10);
return this.formatIfValid({ year, month, day });
}
return null;
}
/**
* Parses textual month representation strings (e.g., "January 5, 2024", "25 Dec 2025").
* Enforces strict check against native date rollover side-effects.
*/
private static parseTextualPatterns(input: string): IsoDateString | null {
const hasMonthName: boolean = /[a-zA-Z]+/i.test(input);
if (!hasMonthName) {
return null;
}
const timestamp: number = Date.parse(input);
if (Number.isNaN(timestamp)) {
return null;
}
const dateObj: Date = new Date(timestamp);
// Verify day/month match input tokens to catch silent month overflow (e.g. Feb 30 -> March 1)
if (!this.verifyTextualComponentsMatch(input, dateObj)) {
return null;
}
const year: number = dateObj.getUTCFullYear();
const month: number = dateObj.getUTCMonth() + 1;
const day: number = dateObj.getUTCDate();
return this.formatIfValid({ year, month, day });
}
/**
* Compares parsed Date object components against original input tokens to ensure zero overflow occurred.
*/
private static verifyTextualComponentsMatch(input: string, dateObj: Date): boolean {
const dayMatch: RegExpMatchArray | null = input.match(/\b(\d{1,2})\b/);
if (!dayMatch) {
return false;
}
const inputDay: number = parseInt(dayMatch[1], 10);
const parsedDay: number = dateObj.getUTCDate();
if (inputDay !== parsedDay) {
return false; // Day rolled over into next month (e.g. Feb 30 -> March 1/2)
}
const monthMatch: RegExpMatchArray | null = input.match(/\b([a-zA-Z]+)\b/);
if (!monthMatch) {
return false;
}
const monthKey: string = monthMatch[1].toLowerCase();
const expectedMonthIndex: number | undefined = this.MONTH_MAP[monthKey];
if (expectedMonthIndex === undefined) {
return false;
}
return dateObj.getUTCMonth() === expectedMonthIndex;
}
/**
* Validates target date components with explicit Gregorian calendar boundary checking.
*/
private static formatIfValid(components: DateComponents): IsoDateString | null {
const { year, month, day } = components;
const targetDate: Date = new Date(Date.UTC(year, month - 1, day));
// Confirm JS Date object constructed exact inputs without internal calendar adjustments
if (
targetDate.getUTCFullYear() === year &&
targetDate.getUTCMonth() === month - 1 &&
targetDate.getUTCDate() === day
) {
const pad = (num: number): string => String(num).padStart(2, '0');
return `${year}-${pad(month)}-${pad(day)}` as IsoDateString;
}
return null;
}
}
// ============================================================================
// Test Execution Suite
// ============================================================================
function runTests(): void {
const testCases: string[] = [
"05/01/2024",
"12-31-2023",
"2024.02.09",
"January 5, 2024",
"2024/09/17",
"2024-09-17",
"25 Dec 2025\u200B", // Trailing zero-width space
"6 Jan 2025",
"Jan 7, 2025",
"8 January 2025",
"\"7/5/2026\"",
"BadFormat",
"Feb 30, 2024",
"April 31, 2024",
];
console.log(`${"Input Date String".padEnd(25)} | ${"Normalized".padEnd(15)} | Status`);
console.log("-".repeat(55));
for (const testInput of testCases) {
const result: IsoDateString | null = DateNormalizer.normalizeToIso(testInput);
const displayInput: string = testInput.replace(/[\u200B-\u200D\uFEFF]/g, '');
const status: string = result !== null ? "VALID" : "INVALID";
console.log(`${displayInput.padEnd(25)} | ${(result ?? "null").padEnd(15)} | ${status}`);
}
}
runTests();
/*
run:
Input Date String | Normalized | Status
-------------------------------------------------------
05/01/2024 | 2024-05-01 | VALID
12-31-2023 | 2023-12-31 | VALID
2024.02.09 | 2024-02-09 | VALID
January 5, 2024 | 2024-01-05 | VALID
2024/09/17 | 2024-09-17 | VALID
2024-09-17 | 2024-09-17 | VALID
25 Dec 2025 | 2025-12-25 | VALID
6 Jan 2025 | 2025-01-06 | VALID
Jan 7, 2025 | 2025-01-07 | VALID
8 January 2025 | 2025-01-08 | VALID
"7/5/2026" | 2026-07-05 | VALID
BadFormat | null | INVALID
Feb 30, 2024 | null | INVALID
April 31, 2024 | null | INVALID
*/