Repository: jquense/yup
Pull request #2080 (2023-08-09 15:07:13)

refactor(parse-iso-date): Convert to TypeScript
The poor `parseIsoDate` util was the only file in the repo that hadn't been converted over to TypeScript back in 2020. In addition, it was written in a way that was hard to read and harder to understand. It also had the linter disabled for the entire file.

This pull request aims to fix all those issues, while maintaining exactly the same functionality. 

To ensure the same functionality, before making any changes I ported over the tests from the original source of the `parseIsoDate` function. Many of those tests no longer passed because of previous changes made to the function, but I modified them to match the module's current functionality. With the tests in place, I was then able to refactor the module and ensure that the functionality remained identical.


Patch:
diff --git a/src/date.ts b/src/date.ts
index 0636baa..34f9169 100644
--- a/src/date.ts
+++ b/src/date.ts
@@ -1,5 +1,4 @@
-// @ts-ignore
-import isoParse from './util/isodate';
+import { parseIsoDate } from './util/parseIsoDate';
 import { date as locale } from './locale';
 import Ref from './Reference';
 import type { AnyObject, DefaultThunk, Message } from './types';
@@ -51,7 +50,7 @@ export default class DateSchema<
         if (!ctx.spec.coerce || ctx.isType(value) || value === null)
           return value;
 
-        value = isoParse(value);
+        value = parseIsoDate(value);
 
         // 0 is a valid timestamp equivalent to 1970-01-01T00:00:00Z(unix epoch) or before.
         return !isNaN(value) ? new Date(value) : DateSchema.INVALID_DATE;
diff --git a/src/util/isodate.js b/src/util/isodate.js
deleted file mode 100644
index 8590bb9..0000000
--- a/src/util/isodate.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/* eslint-disable */
-/**
- *
- * Date.parse with progressive enhancement for ISO 8601 <https://github.com/csnover/js-iso8601>
- * NON-CONFORMANT EDITION.
- * © 2011 Colin Snover <http://zetafleet.com>
- * Released under MIT license.
- */
-
-//              1 YYYY                 2 MM        3 DD              4 HH     5 mm        6 ss            7 msec         8 Z 9 ±    10 tzHH    11 tzmm
-var isoReg = /^(\d{4}|[+\-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,\.](\d{1,}))?)?(?:(Z)|([+\-])(\d{2})(?::?(\d{2}))?)?)?$/;
-
-export default function parseIsoDate(date) {
-  var numericKeys = [1, 4, 5, 6, 7, 10, 11],
-    minutesOffset = 0,
-    timestamp,
-    struct;
-
-  if ((struct = isoReg.exec(date))) {
-    // avoid NaN timestamps caused by “undefined” values being passed to Date.UTC
-    for (var i = 0, k; (k = numericKeys[i]); ++i) struct[k] = +struct[k] || 0;
-
-    // allow undefined days and months
-    struct[2] = (+struct[2] || 1) - 1;
-    struct[3] = +struct[3] || 1;
-
-    // allow arbitrary sub-second precision beyond milliseconds
-    struct[7] = struct[7] ? String(struct[7]).substr(0, 3) : 0;
-
-    // timestamps without timezone identifiers should be considered local time
-    if (
-      (struct[8] === undefined || struct[8] === '') &&
-      (struct[9] === undefined || struct[9] === '')
-    )
-      timestamp = +new Date(
-        struct[1],
-        struct[2],
-        struct[3],
-        struct[4],
-        struct[5],
-        struct[6],
-        struct[7],
-      );
-    else {
-      if (struct[8] !== 'Z' && struct[9] !== undefined) {
-        minutesOffset = struct[10] * 60 + struct[11];
-
-        if (struct[9] === '+') minutesOffset = 0 - minutesOffset;
-      }
-
-      timestamp = Date.UTC(
-        struct[1],
-        struct[2],
-        struct[3],
-        struct[4],
-        struct[5] + minutesOffset,
-        struct[6],
-        struct[7],
-      );
-    }
-  } else timestamp = Date.parse ? Date.parse(date) : NaN;
-
-  return timestamp;
-}
diff --git a/src/util/parseIsoDate.ts b/src/util/parseIsoDate.ts
new file mode 100644
index 0000000..d53c5cc
--- /dev/null
+++ b/src/util/parseIsoDate.ts
@@ -0,0 +1,68 @@
+/**
+ * This file is a modified version of the file from the following repository:
+ * Date.parse with progressive enhancement for ISO 8601 <https://github.com/csnover/js-iso8601>
+ * NON-CONFORMANT EDITION.
+ * © 2011 Colin Snover <http://zetafleet.com>
+ * Released under MIT license.
+ */
+
+// prettier-ignore
+//                1 YYYY                2 MM        3 DD              4 HH     5 mm        6 ss           7 msec         8 Z 9 ±   10 tzHH    11 tzmm
+const isoReg = /^(\d{4}|[+-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,.](\d{1,}))?)?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?)?$/;
+
+function toNumber(str: string, defaultValue = 0) {
+  return Number(str) || defaultValue;
+}
+
+export function parseIsoDate(date: string): number {
+  const regexResult = isoReg.exec(date);
+  if (!regexResult) return Date.parse ? Date.parse(date) : Number.NaN;
+
+  // use of toNumber() avoids NaN timestamps caused by “undefined”
+  // values being passed to Date constructor
+  const struct = {
+    year: toNumber(regexResult[1]),
+    month: toNumber(regexResult[2], 1) - 1,
+    day: toNumber(regexResult[3], 1),
+    hour: toNumber(regexResult[4]),
+    minute: toNumber(regexResult[5]),
+    second: toNumber(regexResult[6]),
+    millisecond: regexResult[7]
+      ? // allow arbitrary sub-second precision beyond milliseconds
+        toNumber(regexResult[7].substring(0, 3))
+      : 0,
+    z: regexResult[8] || undefined,
+    plusMinus: regexResult[9] || undefined,
+    hourOffset: toNumber(regexResult[10]),
+    minuteOffset: toNumber(regexResult[11]),
+  };
+
+  // timestamps without timezone identifiers should be considered local time
+  if (struct.z === undefined && struct.plusMinus === undefined) {
+    return new Date(
+      struct.year,
+      struct.month,
+      struct.day,
+      struct.hour,
+      struct.minute,
+      struct.second,
+      struct.millisecond,
+    ).valueOf();
+  }
+
+  let totalMinutesOffset = 0;
+  if (struct.z !== 'Z' && struct.plusMinus !== undefined) {
+    totalMinutesOffset = struct.hourOffset * 60 + struct.minuteOffset;
+    if (struct.plusMinus === '+') totalMinutesOffset = 0 - totalMinutesOffset;
+  }
+
+  return Date.UTC(
+    struct.year,
+    struct.month,
+    struct.day,
+    struct.hour,
+    struct.minute + totalMinutesOffset,
+    struct.second,
+    struct.millisecond,
+  );
+}


Test patch:
diff --git a/test/util/parseIsoDate.ts b/test/util/parseIsoDate.ts
new file mode 100644
index 0000000..c908201
--- /dev/null
+++ b/test/util/parseIsoDate.ts
@@ -0,0 +1,245 @@
+/**
+ * This file is a modified version of the test file from the following repository:
+ * Date.parse with progressive enhancement for ISO 8601 <https://github.com/csnover/js-iso8601>
+ * NON-CONFORMANT EDITION.
+ * © 2011 Colin Snover <http://zetafleet.com>
+ * Released under MIT license.
+ */
+
+import { parseIsoDate } from '../../src/util/parseIsoDate';
+
+const sixHours = 6 * 60 * 60 * 1000;
+const sixHoursThirty = sixHours + 30 * 60 * 1000;
+const epochLocalTime = new Date(1970, 0, 1, 0, 0, 0, 0).valueOf();
+
+describe('plain date (no time)', () => {
+  describe('valid dates', () => {
+    test('Unix epoch', () => {
+      const result = parseIsoDate('1970-01-01');
+      expect(result).toBe(epochLocalTime);
+    });
+    test('2001', () => {
+      const result = parseIsoDate('2001');
+      const expected = new Date(2001, 0, 1, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('2001-02', () => {
+      const result = parseIsoDate('2001-02');
+      const expected = new Date(2001, 1, 1, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03', () => {
+      const result = parseIsoDate('2001-02-03');
+      const expected = new Date(2001, 1, 3, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-002001', () => {
+      const result = parseIsoDate('-002001');
+      const expected = new Date(-2001, 0, 1, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-002001-02', () => {
+      const result = parseIsoDate('-002001-02');
+      const expected = new Date(-2001, 1, 1, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-002001-02-03', () => {
+      const result = parseIsoDate('-002001-02-03');
+      const expected = new Date(-2001, 1, 3, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('+010000-02', () => {
+      const result = parseIsoDate('+010000-02');
+      const expected = new Date(10000, 1, 1, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('+010000-02-03', () => {
+      const result = parseIsoDate('+010000-02-03');
+      const expected = new Date(10000, 1, 3, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-010000-02', () => {
+      const result = parseIsoDate('-010000-02');
+      const expected = new Date(-10000, 1, 1, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-010000-02-03', () => {
+      const result = parseIsoDate('-010000-02-03');
+      const expected = new Date(-10000, 1, 3, 0, 0, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('invalid dates', () => {
+    test('invalid YYYY (non-digits)', () => {
+      expect(parseIsoDate('asdf')).toBeNaN();
+    });
+    test('invalid YYYY-MM-DD (non-digits)', () => {
+      expect(parseIsoDate('1970-as-df')).toBeNaN();
+    });
+    test('invalid YYYY-MM- (extra hyphen)', () => {
+      expect(parseIsoDate('1970-01-')).toBe(epochLocalTime);
+    });
+    test('invalid YYYY-MM-DD (missing hyphens)', () => {
+      expect(parseIsoDate('19700101')).toBe(epochLocalTime);
+    });
+    test('ambiguous YYYY-MM/YYYYYY (missing plus/minus or hyphen)', () => {
+      expect(parseIsoDate('197001')).toBe(epochLocalTime);
+    });
+  });
+});
+
+describe('date-time', () => {
+  describe('no time zone', () => {
+    test('2001-02-03T04:05', () => {
+      const result = parseIsoDate('2001-02-03T04:05');
+      const expected = new Date(2001, 1, 3, 4, 5, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06');
+      const expected = new Date(2001, 1, 3, 4, 5, 6, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06.007', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06.007');
+      const expected = new Date(2001, 1, 3, 4, 5, 6, 7).valueOf();
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('Z time zone', () => {
+    test('2001-02-03T04:05Z', () => {
+      const result = parseIsoDate('2001-02-03T04:05Z');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0);
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06Z', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06Z');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0);
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06.007Z', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06.007Z');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('offset time zone', () => {
+    test('2001-02-03T04:05-00:00', () => {
+      const result = parseIsoDate('2001-02-03T04:05-00:00');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0);
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06-00:00', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06-00:00');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0);
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06.007-00:00', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06.007-00:00');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7);
+      expect(result).toBe(expected);
+    });
+
+    test('2001-02-03T04:05+00:00', () => {
+      const result = parseIsoDate('2001-02-03T04:05+00:00');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0);
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06+00:00', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06+00:00');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0);
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06.007+00:00', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06.007+00:00');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7);
+      expect(result).toBe(expected);
+    });
+
+    test('2001-02-03T04:05-06:30', () => {
+      const result = parseIsoDate('2001-02-03T04:05-06:30');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) + sixHoursThirty;
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06-06:30', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06-06:30');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) + sixHoursThirty;
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06.007-06:30', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06.007-06:30');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) + sixHoursThirty;
+      expect(result).toBe(expected);
+    });
+
+    test('2001-02-03T04:05+06:30', () => {
+      const result = parseIsoDate('2001-02-03T04:05+06:30');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) - sixHoursThirty;
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06+06:30', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06+06:30');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) - sixHoursThirty;
+      expect(result).toBe(expected);
+    });
+    test('2001-02-03T04:05:06.007+06:30', () => {
+      const result = parseIsoDate('2001-02-03T04:05:06.007+06:30');
+      const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) - sixHoursThirty;
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('incomplete dates', () => {
+    test('2001T04:05:06.007', () => {
+      const result = parseIsoDate('2001T04:05:06.007');
+      const expected = new Date(2001, 0, 1, 4, 5, 6, 7).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('2001-02T04:05:06.007', () => {
+      const result = parseIsoDate('2001-02T04:05:06.007');
+      const expected = new Date(2001, 1, 1, 4, 5, 6, 7).valueOf();
+      expect(result).toBe(expected);
+    });
+
+    test('-010000T04:05', () => {
+      const result = parseIsoDate('-010000T04:05');
+      const expected = new Date(-10000, 0, 1, 4, 5, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-010000-02T04:05', () => {
+      const result = parseIsoDate('-010000-02T04:05');
+      const expected = new Date(-10000, 1, 1, 4, 5, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+    test('-010000-02-03T04:05', () => {
+      const result = parseIsoDate('-010000-02-03T04:05');
+      const expected = new Date(-10000, 1, 3, 4, 5, 0, 0).valueOf();
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('invalid date-times', () => {
+    test('missing T', () => {
+      expect(parseIsoDate('1970-01-01 00:00:00')).toBe(epochLocalTime);
+    });
+    test('too many characters in millisecond part', () => {
+      expect(parseIsoDate('1970-01-01T00:00:00.000000')).toBe(epochLocalTime);
+    });
+    test('comma instead of dot', () => {
+      expect(parseIsoDate('1970-01-01T00:00:00,000')).toBe(epochLocalTime);
+    });
+    test('missing colon in timezone part', () => {
+      const subject = '1970-01-01T00:00:00+0630';
+      expect(parseIsoDate(subject)).toBe(Date.parse(subject));
+    });
+    test('missing colon in time part', () => {
+      expect(parseIsoDate('1970-01-01T0000')).toBe(epochLocalTime);
+    });
+    test('msec with missing seconds', () => {
+      expect(parseIsoDate('1970-01-01T00:00.000')).toBeNaN();
+    });
+  });
+});