Repository: colinhacks/zod
Pull request #4737 (2025-06-18 04:19:56)

Add `z.stringFormat()`
Implement `z.stringFormat()` to enable custom string format schemas. 

```ts
// function form
const a = z.stringFormat("creditCard", (val) => /\d{16}/.test(val))

// regex form
const b = z.stringFormat("creditCard",  /\d{16}/)
```

These schemas produce proper `invalid_format` issues, unlike a naive approach with `z.custom()` or `.refine()`:

```ts
const b = z.stringFormat("creditCard", /\d{16}/);
b.parse("asdf");
// ZodError: [
//   {
//     code: "invalid_format",
//     format: "creditCard",
//     path: [],
//     message: "Invalid creditCard",
//   },
// ];
```




Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 797fff7d..c55fee21 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -6,8 +6,7 @@
   "editor.formatOnSave": true,
   "editor.codeActionsOnSave": {
     "source.organizeImports.biome": "explicit",
-    "source.fixAll": "always",
-    "quickfix.biome": "explicit"
+    "source.fixAll.biome": "always"
   },
   "files.exclude": {
     "./scratch": false,
diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts
index b9c6c8c1..723a64d6 100644
--- a/packages/zod/src/v4/classic/schemas.ts
+++ b/packages/zod/src/v4/classic/schemas.ts
@@ -370,7 +370,7 @@ export function string(params?: string | core.$ZodStringParams): ZodString {
 }
 
 // ZodStringFormat
-export interface ZodStringFormat<Format extends core.$ZodStringFormats = core.$ZodStringFormats>
+export interface ZodStringFormat<Format extends string = string>
   extends _ZodString<core.$ZodStringFormatInternals<Format>> {}
 export const ZodStringFormat: core.$constructor<ZodStringFormat> = /*@__PURE__*/ core.$constructor(
   "ZodStringFormat",
@@ -674,6 +674,28 @@ export function jwt(params?: string | core.$ZodJWTParams): ZodJWT {
   return core._jwt(ZodJWT, params);
 }
 
+// ZodCustomStringFormat
+export interface ZodCustomStringFormat<Format extends string = string>
+  extends ZodStringFormat<Format>,
+    core.$ZodCustomStringFormat<Format> {
+  _zod: core.$ZodCustomStringFormatInternals<Format>;
+}
+export const ZodCustomStringFormat: core.$constructor<ZodCustomStringFormat> = /*@__PURE__*/ core.$constructor(
+  "ZodCustomStringFormat",
+  (inst, def) => {
+    // ZodStringFormat.init(inst, def);
+    core.$ZodCustomStringFormat.init(inst, def);
+    ZodStringFormat.init(inst, def);
+  }
+);
+export function stringFormat<Format extends string>(
+  format: Format,
+  fnOrRegex: ((arg: string) => util.MaybeAsync<unknown>) | RegExp,
+  _params: string | core.$ZodStringFormatParams = {}
+): ZodCustomStringFormat<Format> {
+  return core._stringFormat(ZodCustomStringFormat, format, fnOrRegex, _params) as any;
+}
+
 // ZodNumber
 export interface _ZodNumber<Internals extends core.$ZodNumberInternals = core.$ZodNumberInternals>
   extends _ZodType<Internals> {
@@ -1911,7 +1933,6 @@ export const ZodCustom: core.$constructor<ZodCustom> = /*@__PURE__*/ core.$const
 export function check<O = unknown>(fn: core.CheckFn<O>, params?: string | core.$ZodCustomParams): core.$ZodCheck<O> {
   const ch = new core.$ZodCheck({
     check: "custom",
-
     ...util.normalizeParams(params),
   });
 
diff --git a/packages/zod/src/v4/core/api.ts b/packages/zod/src/v4/core/api.ts
index f9811835..72e141a2 100644
--- a/packages/zod/src/v4/core/api.ts
+++ b/packages/zod/src/v4/core/api.ts
@@ -79,6 +79,10 @@ export function _coercedString<T extends schemas.$ZodString>(
   });
 }
 
+export type $ZodStringFormatParams = CheckTypeParams<schemas.$ZodStringFormat, "format" | "coerce">;
+export type $ZodCheckStringFormatParams = CheckParams<checks.$ZodCheckStringFormat, "format">;
+// custom format
+
 // Email
 export type $ZodEmailParams = StringFormatParams<schemas.$ZodEmail>;
 export type $ZodCheckEmailParams = CheckStringFormatParams<schemas.$ZodEmail>;
@@ -1562,3 +1566,26 @@ export function _stringbool(
   });
   return outerPipe as any;
 }
+
+export function _stringFormat<Format extends string>(
+  Class: typeof schemas.$ZodCustomStringFormat,
+  format: Format,
+  fnOrRegex: ((arg: string) => util.MaybeAsync<unknown>) | RegExp,
+  _params: string | $ZodStringFormatParams = {}
+): schemas.$ZodCustomStringFormat<Format> {
+  const params = util.normalizeParams(_params);
+  const def: schemas.$ZodCustomStringFormatDef = {
+    ...util.normalizeParams(_params),
+    check: "string_format",
+    type: "string",
+    format,
+    fn: typeof fnOrRegex === "function" ? fnOrRegex : (val) => fnOrRegex.test(val),
+    ...params,
+  };
+  if (fnOrRegex instanceof RegExp) {
+    def.pattern = fnOrRegex;
+  }
+
+  const inst = new Class(def);
+  return inst as any;
+}
diff --git a/packages/zod/src/v4/core/checks.ts b/packages/zod/src/v4/core/checks.ts
index 79651bb8..4b83d21b 100644
--- a/packages/zod/src/v4/core/checks.ts
+++ b/packages/zod/src/v4/core/checks.ts
@@ -770,7 +770,7 @@ export type $ZodStringFormats =
   | "starts_with"
   | "ends_with"
   | "includes";
-export interface $ZodCheckStringFormatDef<Format extends $ZodStringFormats = $ZodStringFormats> extends $ZodCheckDef {
+export interface $ZodCheckStringFormatDef<Format extends string = string> extends $ZodCheckDef {
   check: "string_format";
   format: Format;
   pattern?: RegExp | undefined;
@@ -799,20 +799,21 @@ export const $ZodCheckStringFormat: core.$constructor<$ZodCheckStringFormat> = /
       }
     });
 
-    inst._zod.check ??= (payload) => {
-      if (!def.pattern) throw new Error("Not implemented.");
-      def.pattern.lastIndex = 0;
-      if (def.pattern.test(payload.value)) return;
-      payload.issues.push({
-        origin: "string",
-        code: "invalid_format",
-        format: def.format,
-        input: payload.value,
-        ...(def.pattern ? { pattern: def.pattern.toString() } : {}),
-        inst,
-        continue: !def.abort,
-      });
-    };
+    if (def.pattern)
+      inst._zod.check ??= (payload) => {
+        def.pattern!.lastIndex = 0;
+        if (def.pattern!.test(payload.value)) return;
+        payload.issues.push({
+          origin: "string",
+          code: "invalid_format",
+          format: def.format,
+          input: payload.value,
+          ...(def.pattern ? { pattern: def.pattern.toString() } : {}),
+          inst,
+          continue: !def.abort,
+        });
+      };
+    else inst._zod.check ??= () => {};
   }
 );
 
@@ -1279,26 +1280,4 @@ export type $ZodStringFormatChecks =
   | $ZodCheckIncludes
   | $ZodCheckStartsWith
   | $ZodCheckEndsWith
-  | schemas.$ZodGUID
-  | schemas.$ZodUUID
-  | schemas.$ZodEmail
-  | schemas.$ZodURL
-  | schemas.$ZodEmoji
-  | schemas.$ZodNanoID
-  | schemas.$ZodCUID
-  | schemas.$ZodCUID2
-  | schemas.$ZodULID
-  | schemas.$ZodXID
-  | schemas.$ZodKSUID
-  | schemas.$ZodISODateTime
-  | schemas.$ZodISODate
-  | schemas.$ZodISOTime
-  | schemas.$ZodISODuration
-  | schemas.$ZodIPv4
-  | schemas.$ZodIPv6
-  | schemas.$ZodCIDRv4
-  | schemas.$ZodCIDRv6
-  | schemas.$ZodBase64
-  | schemas.$ZodBase64URL
-  | schemas.$ZodE164
-  | schemas.$ZodJWT;
+  | schemas.$ZodStringFormatTypes; // union of string format schema types
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index 876a31ec..7cd3ac80 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -326,16 +326,16 @@ export const $ZodString: core.$constructor<$ZodString> = /*@__PURE__*/ core.$con
 
 //////////////////////////////   ZodStringFormat   //////////////////////////////
 
-export interface $ZodStringFormatDef<Format extends checks.$ZodStringFormats = checks.$ZodStringFormats>
+export interface $ZodStringFormatDef<Format extends string = string>
   extends $ZodStringDef,
     checks.$ZodCheckStringFormatDef<Format> {}
 
-export interface $ZodStringFormatInternals<Format extends checks.$ZodStringFormats = checks.$ZodStringFormats>
+export interface $ZodStringFormatInternals<Format extends string = string>
   extends $ZodStringInternals<string>,
     checks.$ZodCheckStringFormatInternals {
   def: $ZodStringFormatDef<Format>;
 }
-export interface $ZodStringFormat<Format extends checks.$ZodStringFormats = checks.$ZodStringFormats> extends $ZodType {
+export interface $ZodStringFormat<Format extends string = string> extends $ZodType {
   _zod: $ZodStringFormatInternals<Format>;
 }
 
@@ -932,6 +932,39 @@ export const $ZodJWT: core.$constructor<$ZodJWT> = /*@__PURE__*/ core.$construct
   };
 });
 
+//////////////////////////////   ZodCustomStringFormat   //////////////////////////////
+
+export interface $ZodCustomStringFormatDef<Format extends string = string> extends $ZodStringFormatDef<Format> {
+  fn: (val: string) => unknown;
+}
+
+export interface $ZodCustomStringFormatInternals<Format extends string = string>
+  extends $ZodStringFormatInternals<Format> {
+  def: $ZodCustomStringFormatDef<Format>;
+}
+
+export interface $ZodCustomStringFormat<Format extends string = string> extends $ZodStringFormat<Format> {
+  _zod: $ZodCustomStringFormatInternals<Format>;
+}
+
+export const $ZodCustomStringFormat: core.$constructor<$ZodCustomStringFormat> = /*@__PURE__*/ core.$constructor(
+  "$ZodCustomStringFormat",
+  (inst, def): void => {
+    $ZodStringFormat.init(inst, def);
+    inst._zod.check = (payload) => {
+      if (def.fn(payload.value)) return;
+
+      payload.issues.push({
+        code: "invalid_format",
+        format: def.format,
+        input: payload.value,
+        inst,
+        continue: !def.abort,
+      });
+    };
+  }
+);
+
 /////////////////////////////////////////
 /////////////////////////////////////////
 //////////                     //////////
diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts
index 4031edbf..48380777 100644
--- a/packages/zod/src/v4/mini/schemas.ts
+++ b/packages/zod/src/v4/mini/schemas.ts
@@ -94,7 +94,7 @@ export function string(params?: string | core.$ZodStringParams): ZodMiniString<s
 }
 
 // ZodMiniStringFormat
-export interface ZodMiniStringFormat<Format extends core.$ZodStringFormats = core.$ZodStringFormats>
+export interface ZodMiniStringFormat<Format extends string = string>
   extends _ZodMiniString<core.$ZodStringFormatInternals<Format>>,
     core.$ZodStringFormat<Format> {
   // _zod: core.$ZodStringFormatInternals<Format>;
@@ -414,6 +414,28 @@ export function jwt(params?: string | core.$ZodJWTParams): ZodMiniJWT {
   return core._jwt(ZodMiniJWT, params);
 }
 
+// ZodMiniCustomStringFormat
+export interface ZodMiniCustomStringFormat<Format extends string = string>
+  extends ZodMiniStringFormat<Format>,
+    core.$ZodCustomStringFormat<Format> {
+  _zod: core.$ZodCustomStringFormatInternals<Format>;
+}
+export const ZodMiniCustomStringFormat: core.$constructor<ZodMiniCustomStringFormat> = /*@__PURE__*/ core.$constructor(
+  "ZodMiniCustomStringFormat",
+  (inst, def) => {
+    core.$ZodCustomStringFormat.init(inst, def);
+    ZodMiniStringFormat.init(inst, def);
+  }
+);
+
+export function stringFormat<Format extends string>(
+  format: Format,
+  fnOrRegex: ((arg: string) => util.MaybeAsync<unknown>) | RegExp,
+  _params: string | core.$ZodStringFormatParams = {}
+): ZodMiniCustomStringFormat<Format> {
+  return core._stringFormat(ZodMiniCustomStringFormat, format, fnOrRegex, _params) as any;
+}
+
 // ZodMiniNumber
 interface _ZodMiniNumber<T extends core.$ZodNumberInternals<unknown> = core.$ZodNumberInternals<unknown>>
   extends _ZodMiniType<T>,
diff --git a/play.ts b/play.ts
index 8a7f605d..41eb006b 100644
--- a/play.ts
+++ b/play.ts
@@ -1,5 +1,3 @@
 import { z } from "zod/v4";
 
-z.string().parse(12, {
-  reportInput: true,
-});
+z;


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/string-formats.test.ts b/packages/zod/src/v4/classic/tests/string-formats.test.ts
index f73bd886..94fdedf9 100644
--- a/packages/zod/src/v4/classic/tests/string-formats.test.ts
+++ b/packages/zod/src/v4/classic/tests/string-formats.test.ts
@@ -29,3 +29,81 @@ test("string format methods", () => {
   expect(e.safeParse("email@example.com").success).toBe(true); // Positive
   expect(e.safeParse("EMAIL@EXAMPLE.COM").success).toBe(false); // Negative
 });
+
+test("z.stringFormat", () => {
+  const ccRegex = /^(?:\d{14,19}|\d{4}(?: \d{3,6}){2,4}|\d{4}(?:-\d{3,6}){2,4})$/u;
+
+  const a = z
+    .stringFormat("creditCard", (val) => ccRegex.test(val), {
+      error: `Invalid credit card number`,
+    })
+    .refine((_) => false, "Also bad");
+
+  expect(a.safeParse("asdf")).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "code": "invalid_format",
+        "format": "creditCard",
+        "path": [],
+        "message": "Invalid credit card number"
+      },
+      {
+        "code": "custom",
+        "path": [],
+        "message": "Also bad"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+  expect(a.safeParse("1234-5678-9012-3456")).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "code": "custom",
+        "path": [],
+        "message": "Also bad"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+  expect(a.def.pattern).toMatchInlineSnapshot(`undefined`);
+
+  const b = z
+    .stringFormat("creditCard", ccRegex, {
+      abort: true,
+      error: `Invalid credit card number`,
+    })
+    .refine((_) => false, "Also bad");
+
+  expect(b.safeParse("asdf")).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "code": "invalid_format",
+        "format": "creditCard",
+        "path": [],
+        "message": "Invalid credit card number"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+  expect(b.safeParse("1234-5678-9012-3456")).toMatchInlineSnapshot(`
+    {
+      "error": [ZodError: [
+      {
+        "code": "custom",
+        "path": [],
+        "message": "Also bad"
+      }
+    ]],
+      "success": false,
+    }
+  `);
+  expect(b.def.pattern).toMatchInlineSnapshot(
+    `/\\^\\(\\?:\\\\d\\{14,19\\}\\|\\\\d\\{4\\}\\(\\?: \\\\d\\{3,6\\}\\)\\{2,4\\}\\|\\\\d\\{4\\}\\(\\?:-\\\\d\\{3,6\\}\\)\\{2,4\\}\\)\\$/u`
+  );
+});