How to convert multiple date formats to YYYY-MM-DD in TypeScript

1 Answer

0 votes
/**
 * 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

*/

 



answered Sep 24 by avibootz
...