Repository: colinhacks/zod
Pull request #4661 (2025-06-10 02:24:27)

Improve stringbool
Closes https://github.com/colinhacks/zod/issues/4639


Patch:
diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts
index 50cb0547..b1cb77f6 100644
--- a/packages/zod/src/v4/classic/schemas.ts
+++ b/packages/zod/src/v4/classic/schemas.ts
@@ -1984,14 +1984,15 @@ function _instanceof<T extends typeof util.Class>(
 export { _instanceof as instanceof };
 
 // stringbool
-export const stringbool: (_params?: string | core.$ZodStringBoolParams) => ZodPipe<ZodUnknown, ZodBoolean> = (
-  ...args
-) =>
+export const stringbool: (
+  _params?: string | core.$ZodStringBoolParams
+) => ZodPipe<ZodPipe<ZodString, ZodTransform<boolean, string>>, ZodBoolean> = (...args) =>
   core._stringbool(
     {
       Pipe: ZodPipe,
       Boolean: ZodBoolean,
-      Unknown: ZodUnknown,
+      String: ZodString,
+      Transform: ZodTransform,
     },
     ...args
   ) as any;
diff --git a/packages/zod/src/v4/core/api.ts b/packages/zod/src/v4/core/api.ts
index 2e9da61b..ab6efb75 100644
--- a/packages/zod/src/v4/core/api.ts
+++ b/packages/zod/src/v4/core/api.ts
@@ -1478,9 +1478,9 @@ export interface $ZodStringBoolParams extends TypeParams {
   truthy?: string[];
   falsy?: string[];
   /**
-   * Options `"sensitive"`, `"insensitive"`
+   * Options: `"sensitive"`, `"insensitive"`
    *
-   * Defaults to `"insensitive"`
+   * @default `"insensitive"`
    */
   case?: "sensitive" | "insensitive" | undefined;
 }
@@ -1489,66 +1489,69 @@ export function _stringbool(
   Classes: {
     Pipe?: typeof schemas.$ZodPipe;
     Boolean?: typeof schemas.$ZodBoolean;
-    Unknown?: typeof schemas.$ZodUnknown;
+    Transform?: typeof schemas.$ZodTransform;
+    String?: typeof schemas.$ZodString;
   },
   _params?: string | $ZodStringBoolParams
-): schemas.$ZodPipe<schemas.$ZodUnknown, schemas.$ZodBoolean<boolean>> {
+): schemas.$ZodPipe<
+  schemas.$ZodPipe<schemas.$ZodString, schemas.$ZodTransform<boolean, string>>,
+  schemas.$ZodBoolean<boolean>
+> {
   const { case: _case, error, truthy, falsy } = util.normalizeParams(_params);
 
-  const trueValues = new Set(truthy ?? ["true", "1", "yes", "on", "y", "enabled"]);
-  const falseValues = new Set(falsy ?? ["false", "0", "no", "off", "n", "disabled"]);
+  let truthyArray = truthy ?? ["true", "1", "yes", "on", "y", "enabled"];
+  let falsyArray = falsy ?? ["false", "0", "no", "off", "n", "disabled"];
+  if (_case !== "sensitive") {
+    truthyArray = truthyArray.map((v) => (typeof v === "string" ? v.toLowerCase() : v));
+    falsyArray = falsyArray.map((v) => (typeof v === "string" ? v.toLowerCase() : v));
+  }
+
+  const truthySet = new Set(truthyArray);
+  const falsySet = new Set(falsyArray);
 
   const _Pipe = Classes.Pipe ?? schemas.$ZodPipe;
   const _Boolean = Classes.Boolean ?? schemas.$ZodBoolean;
-  const _Unknown = Classes.Unknown ?? schemas.$ZodUnknown;
+  const _String = Classes.String ?? schemas.$ZodString;
+  const _Transform = Classes.Transform ?? schemas.$ZodTransform;
 
-  const inst = new _Unknown({
-    type: "unknown",
-    checks: [
-      {
-        _zod: {
-          check: (ctx: any) => {
-            if (typeof ctx.value === "string") {
-              let data: string = ctx.value;
-              if (_case !== "sensitive") data = data.toLowerCase();
-              if (trueValues.has(data)) {
-                ctx.value = true;
-              } else if (falseValues.has(data)) {
-                ctx.value = false;
-              } else {
-                ctx.issues.push({
-                  code: "invalid_value",
-                  expected: "stringbool",
-                  values: [...trueValues, ...falseValues],
-                  input: ctx.value,
-                  inst,
-                });
-              }
-            } else {
-              ctx.issues.push({
-                code: "invalid_type",
-                expected: "string",
-                input: ctx.value,
-              });
-            }
-          },
-          def: {
-            check: "custom",
-          },
-          onattach: [],
-        },
-      },
-    ],
+  const tx = new _Transform({
+    type: "transform",
+    transform: (input, payload: schemas.ParsePayload<unknown>) => {
+      let data: string = input as string;
+      if (_case !== "sensitive") data = data.toLowerCase();
+      if (truthySet.has(data)) {
+        return true;
+      } else if (falsySet.has(data)) {
+        return false;
+      } else {
+        payload.issues.push({
+          code: "invalid_value",
+          expected: "stringbool",
+          values: [...truthySet, ...falsySet],
+          input: payload.value,
+          inst: tx,
+        });
+        return {} as never;
+      }
+    },
+    error,
+  });
+
+  const innerPipe = new _Pipe({
+    type: "pipe",
+    in: new _String({ type: "string", error }),
+    out: tx,
     error,
   });
 
-  return new _Pipe({
+  const outerPipe = new _Pipe({
     type: "pipe",
-    in: inst,
+    in: innerPipe,
     out: new _Boolean({
       type: "boolean",
       error,
     }),
     error,
-  }) as any;
+  });
+  return outerPipe as any;
 }
diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts
index af968fcb..dfef934e 100644
--- a/packages/zod/src/v4/mini/schemas.ts
+++ b/packages/zod/src/v4/mini/schemas.ts
@@ -1507,12 +1507,13 @@ export { _instanceof as instanceof };
 // stringbool
 export const stringbool: (
   _params?: string | core.$ZodStringBoolParams
-) => ZodMiniPipe<ZodMiniUnknown, ZodMiniBoolean<boolean>> = (...args) =>
+) => ZodMiniPipe<ZodMiniPipe<ZodMiniString, ZodMiniTransform<boolean, string>>, ZodMiniBoolean> = (...args) =>
   core._stringbool(
     {
       Pipe: ZodMiniPipe,
       Boolean: ZodMiniBoolean,
-      Unknown: ZodMiniUnknown,
+      String: ZodMiniString,
+      Transform: ZodMiniTransform,
     },
     ...args
   ) as any;
diff --git a/play.ts b/play.ts
index d6648572..41eb006b 100644
--- a/play.ts
+++ b/play.ts
@@ -1,75 +1,3 @@
 import { z } from "zod/v4";
 
-const MY_KEYS = ["foo", "bar", "baz"] as const;
-const MY_BRAND = "Label";
-
-const MyKeySchema = z.literal([...MY_KEYS]).brand(MY_BRAND);
-const MyRecordSchema = z.record(MyKeySchema, z.number());
-MyKeySchema._zod.values;
-
-const myRecord = MyRecordSchema.parse({
-  //  ^?
-  foo: 1,
-  bar: 2,
-  baz: 3,
-});
-
-const foo = MyKeySchema.parse("foo") as any as "foo" & z.core.$brand<"Label">;
-
-// this should work, as foo has been branded
-const fooValue = myRecord[foo];
-//    ^?
-
-// this should be a type error, as "bar" hasn't been branded
-const barValue = myRecord["bar"];
-//    ^?
-
-// this should work as myRecord has been parsed but im pretty sure thats impossible in ts
-const bazValue = myRecord.baz;
-//    ^?
-
-type ZodSymbol<T extends z.ZodLiteral, L extends number | string | symbol> = symbol & z.core.$ZodBranded<T, L>;
-
-type ZodSymbols<T extends z.core.$ZodBranded<z.ZodType, string>> = T extends z.core.$ZodBranded<infer U, infer _>
-  ? U extends z.ZodLiteral<infer Lits>
-    ? {
-        // we create a mapped type here so we can just use [k in keyof ZodSymbols<T>] to iterate over the keys
-        [k in Lits as k extends number | string | symbol ? ZodSymbol<U, k> : never]: never;
-      }
-    : never
-  : never;
-
-type MyLabelAsSymbols = ZodSymbols<typeof MyKeySchema>;
-//   ^?
-
-// this would be the output of parsing
-type MyRecordFixed<T extends z.ZodRecord> = T["def"]["keyType"] extends z.core.$ZodBranded<
-  z.ZodType,
-  number | string | symbol
->
-  ? {
-      [k in keyof ZodSymbols<T["def"]["keyType"]>]: z.infer<T["def"]["valueType"]>;
-    }
-  : z.infer<T>;
-
-type MyRecordWithBrandedKeys = MyRecordFixed<typeof MyRecordSchema>;
-//   ^?
-
-// pretend this would be returned by MyRecordWithBrandedKeys.parse
-const myRecordWithBrandedKeys = {} as MyRecordWithBrandedKeys;
-
-// @ts-expect-error pretend this exists
-const MySymbolsSchema = z.symbol(["foo", "bar", "baz"] as const).brand("Label");
-
-// pretend this works and returns ZodSymbol
-const fooKey = MySymbolsSchema.parse("foo") as unknown as ZodSymbol<typeof MyKeySchema, "foo">;
-
-// now we can use the symbol to access the record with branded keys
-const fooValueFixed = myRecordWithBrandedKeys[fooKey];
-//    ^?
-
-const barValueFixed = myRecordWithBrandedKeys["bar"];
-//    ^? // should be a type error, as "bar" is not branded
-
-// note: property access will not work without branded keys - one has to get the symbol first
-const bazValueFixed = myRecordWithBrandedKeys.baz;
+z;


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/stringbool.test.ts b/packages/zod/src/v4/classic/tests/stringbool.test.ts
index f0cd31ea..2fe6c913 100644
--- a/packages/zod/src/v4/classic/tests/stringbool.test.ts
+++ b/packages/zod/src/v4/classic/tests/stringbool.test.ts
@@ -5,6 +5,8 @@ test("z.stringbool", () => {
   const a = z.stringbool();
   type a = z.infer<typeof a>;
   expectTypeOf<a>().toEqualTypeOf<boolean>();
+  type a_in = z.input<typeof a>;
+  expectTypeOf<a_in>().toEqualTypeOf<string>();
 
   expect(z.parse(a, "true")).toEqual(true);
   expect(z.parse(a, "yes")).toEqual(true);
@@ -28,20 +30,31 @@ test("z.stringbool", () => {
   expect(z.safeParse(a, {})).toMatchObject({ success: false });
   expect(z.safeParse(a, true)).toMatchObject({ success: false });
   expect(z.safeParse(a, false)).toMatchObject({ success: false });
+});
 
+test("custom values", () => {
   const b = z.stringbool({
     truthy: ["y"],
-    falsy: ["n"],
+    falsy: ["N"],
   });
   expect(z.parse(b, "y")).toEqual(true);
+  expect(z.parse(b, "Y")).toEqual(true);
   expect(z.parse(b, "n")).toEqual(false);
+  expect(z.parse(b, "N")).toEqual(false);
   expect(z.safeParse(b, "true")).toMatchObject({ success: false });
   expect(z.safeParse(b, "false")).toMatchObject({ success: false });
+});
 
+test("custom values - case sensitive", () => {
   const c = z.stringbool({
+    truthy: ["y"],
+    falsy: ["N"],
     case: "sensitive",
   });
-  expect(z.parse(c, "true")).toEqual(true);
+  expect(z.parse(c, "y")).toEqual(true);
+  expect(z.safeParse(c, "Y")).toMatchObject({ success: false });
+  expect(z.parse(c, "N")).toEqual(false);
+  expect(z.safeParse(c, "n")).toMatchObject({ success: false });
   expect(z.safeParse(c, "TRUE")).toMatchObject({ success: false });
 });