Repository: colinhacks/zod
Pull request #4676 (2025-06-11 20:25:18)

Allow hours-only datetime offsets w/ normalization


Patch:
diff --git a/packages/docs/content/api.mdx b/packages/docs/content/api.mdx
index a3bb3138..47e792c3 100644
--- a/packages/docs/content/api.mdx
+++ b/packages/docs/content/api.mdx
@@ -366,11 +366,13 @@ To allow timezone offsets:
 ```ts
 const datetime = z.iso.datetime({ offset: true });
 
-datetime.parse("2020-01-01T00:00:00+02:00"); // ✅
-datetime.parse("2020-01-01T00:00:00.123+02:00"); // ✅ (millis optional)
-datetime.parse("2020-01-01T00:00:00.123+0200"); // ✅ (millis optional)
-datetime.parse("2020-01-01T00:00:00.123+02"); // ✅ (only offset hours)
-datetime.parse("2020-01-01T00:00:00Z"); // ✅ (Z still supported)
+// result is normalized to RFC 3339 format
+datetime.parse("2020-01-01T00:00:00+02");    // ✅ "2020-01-01T00:00:00+02:00"
+datetime.parse("2020-01-01T00:00:00+0200");  // ✅ "2020-01-01T00:00:00+02:00"
+datetime.parse("2020-01-01T00:00:00+02:00"); // ✅ "2020-01-01T00:00:00+02:00"
+
+// Z is still supported
+datetime.parse("2020-01-01T00:00:00Z"); // ✅ 
 ```
 
 To allow unqualified (timezone-less) datetimes:
diff --git a/packages/zod/src/v4/core/regexes.ts b/packages/zod/src/v4/core/regexes.ts
index 3f710b9a..9cc1d488 100644
--- a/packages/zod/src/v4/core/regexes.ts
+++ b/packages/zod/src/v4/core/regexes.ts
@@ -107,7 +107,9 @@ export function datetime(args: {
 
   const opts: string[] = [];
   opts.push(args.local ? `Z?` : `Z`);
-  if (args.offset) opts.push(`([+-]\\d{2}:?\\d{2})`);
+  // if (args.offset) opts.push(`([+-]\\d{2}:?\\d{2})`);
+  // minutes, colon optional
+  if (args.offset) opts.push(`([+-]\\d{2}(?::?\\d{2})?)`);
   regex = `${regex}(${opts.join("|")})`;
   return new RegExp(`^${regex}$`);
 }
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index 0ee00324..e2ace79e 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -604,6 +604,20 @@ export const $ZodISODateTime: core.$constructor<$ZodISODateTime> = /*@__PURE__*/
   (inst, def): void => {
     def.pattern ??= regexes.datetime(def);
     $ZodStringFormat.init(inst, def);
+
+    const _super = inst._zod.check;
+    inst._zod.check = (payload) => {
+      _super(payload);
+
+      // normalize timezone offset
+      // add colon & minutes if missing
+      // if no offset, return early
+      const curr = payload.value;
+      if (/[+-]\d\d$/.test(curr)) payload.value = curr + ":00";
+      else if (/[+-]\d\d\d\d$/.test(curr)) {
+        payload.value = curr.slice(0, -2) + ":" + curr.slice(-2);
+      }
+    };
   }
 );
 
diff --git a/play.ts b/play.ts
index 41eb006b..0a9320ce 100644
--- a/play.ts
+++ b/play.ts
@@ -1,3 +1,14 @@
 import { z } from "zod/v4";
 
 z;
+
+// const schema = z.iso.datetime({ offset: true, local: true });
+// console.dir(schema.parse("2023-10-01T12:00:00.132"), { depth: null });
+
+const datetime = z.iso.datetime({ offset: true });
+
+datetime.parse("2020-01-01T00:00:00+02:00"); // ✅
+datetime.parse("2020-01-01T00:00:00.123+02:00"); // ✅ (millis optional)
+datetime.parse("2020-01-01T00:00:00.123+0200"); // ✅ (millis optional)
+datetime.parse("2020-01-01T00:00:00.123+02"); // ✅ (only offset hours)
+datetime.parse("2020-01-01T00:00:00Z"); // ✅ (Z still supported)


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/string.test.ts b/packages/zod/src/v4/classic/tests/string.test.ts
index 18b37450..b470873b 100644
--- a/packages/zod/src/v4/classic/tests/string.test.ts
+++ b/packages/zod/src/v4/classic/tests/string.test.ts
@@ -816,6 +816,21 @@ test("datetime parsing", () => {
   expect(() => datetimeOffset4Ms.parse("2020-10-14T17:42:29.124+00:00")).toThrow();
 });
 
+test("datetime offset normalization", () => {
+  const a = z.iso.datetime({ offset: true });
+  expect({
+    a: a.parse("2020-10-14T17:42:29+02"),
+    b: a.parse("2020-10-14T17:42:29+0200"),
+    c: a.parse("2020-10-14T17:42:29+02:00"),
+  }).toMatchInlineSnapshot(`
+    {
+      "a": "2020-10-14T17:42:29+02:00",
+      "b": "2020-10-14T17:42:29+02:00",
+      "c": "2020-10-14T17:42:29+02:00",
+    }
+  `);
+});
+
 test("date parsing", () => {
   const date = z.string().date();
   date.parse("1970-01-01");