Skip to content

Commit 32ae1cd

Browse files
authored
Improve stringbool (#4661)
1 parent fa83a8a commit 32ae1cd

File tree

5 files changed

+74
-128
lines changed

5 files changed

+74
-128
lines changed

packages/zod/src/v4/classic/schemas.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,14 +1984,15 @@ function _instanceof<T extends typeof util.Class>(
19841984
export { _instanceof as instanceof };
19851985

19861986
// stringbool
1987-
export const stringbool: (_params?: string | core.$ZodStringBoolParams) => ZodPipe<ZodUnknown, ZodBoolean> = (
1988-
...args
1989-
) =>
1987+
export const stringbool: (
1988+
_params?: string | core.$ZodStringBoolParams
1989+
) => ZodPipe<ZodPipe<ZodString, ZodTransform<boolean, string>>, ZodBoolean> = (...args) =>
19901990
core._stringbool(
19911991
{
19921992
Pipe: ZodPipe,
19931993
Boolean: ZodBoolean,
1994-
Unknown: ZodUnknown,
1994+
String: ZodString,
1995+
Transform: ZodTransform,
19951996
},
19961997
...args
19971998
) as any;

packages/zod/src/v4/classic/tests/stringbool.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ test("z.stringbool", () => {
55
const a = z.stringbool();
66
type a = z.infer<typeof a>;
77
expectTypeOf<a>().toEqualTypeOf<boolean>();
8+
type a_in = z.input<typeof a>;
9+
expectTypeOf<a_in>().toEqualTypeOf<string>();
810

911
expect(z.parse(a, "true")).toEqual(true);
1012
expect(z.parse(a, "yes")).toEqual(true);
@@ -28,20 +30,31 @@ test("z.stringbool", () => {
2830
expect(z.safeParse(a, {})).toMatchObject({ success: false });
2931
expect(z.safeParse(a, true)).toMatchObject({ success: false });
3032
expect(z.safeParse(a, false)).toMatchObject({ success: false });
33+
});
3134

35+
test("custom values", () => {
3236
const b = z.stringbool({
3337
truthy: ["y"],
34-
falsy: ["n"],
38+
falsy: ["N"],
3539
});
3640
expect(z.parse(b, "y")).toEqual(true);
41+
expect(z.parse(b, "Y")).toEqual(true);
3742
expect(z.parse(b, "n")).toEqual(false);
43+
expect(z.parse(b, "N")).toEqual(false);
3844
expect(z.safeParse(b, "true")).toMatchObject({ success: false });
3945
expect(z.safeParse(b, "false")).toMatchObject({ success: false });
46+
});
4047

48+
test("custom values - case sensitive", () => {
4149
const c = z.stringbool({
50+
truthy: ["y"],
51+
falsy: ["N"],
4252
case: "sensitive",
4353
});
44-
expect(z.parse(c, "true")).toEqual(true);
54+
expect(z.parse(c, "y")).toEqual(true);
55+
expect(z.safeParse(c, "Y")).toMatchObject({ success: false });
56+
expect(z.parse(c, "N")).toEqual(false);
57+
expect(z.safeParse(c, "n")).toMatchObject({ success: false });
4558
expect(z.safeParse(c, "TRUE")).toMatchObject({ success: false });
4659
});
4760

packages/zod/src/v4/core/api.ts

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,9 +1478,9 @@ export interface $ZodStringBoolParams extends TypeParams {
14781478
truthy?: string[];
14791479
falsy?: string[];
14801480
/**
1481-
* Options `"sensitive"`, `"insensitive"`
1481+
* Options: `"sensitive"`, `"insensitive"`
14821482
*
1483-
* Defaults to `"insensitive"`
1483+
* @default `"insensitive"`
14841484
*/
14851485
case?: "sensitive" | "insensitive" | undefined;
14861486
}
@@ -1489,66 +1489,69 @@ export function _stringbool(
14891489
Classes: {
14901490
Pipe?: typeof schemas.$ZodPipe;
14911491
Boolean?: typeof schemas.$ZodBoolean;
1492-
Unknown?: typeof schemas.$ZodUnknown;
1492+
Transform?: typeof schemas.$ZodTransform;
1493+
String?: typeof schemas.$ZodString;
14931494
},
14941495
_params?: string | $ZodStringBoolParams
1495-
): schemas.$ZodPipe<schemas.$ZodUnknown, schemas.$ZodBoolean<boolean>> {
1496+
): schemas.$ZodPipe<
1497+
schemas.$ZodPipe<schemas.$ZodString, schemas.$ZodTransform<boolean, string>>,
1498+
schemas.$ZodBoolean<boolean>
1499+
> {
14961500
const { case: _case, error, truthy, falsy } = util.normalizeParams(_params);
14971501

1498-
const trueValues = new Set(truthy ?? ["true", "1", "yes", "on", "y", "enabled"]);
1499-
const falseValues = new Set(falsy ?? ["false", "0", "no", "off", "n", "disabled"]);
1502+
let truthyArray = truthy ?? ["true", "1", "yes", "on", "y", "enabled"];
1503+
let falsyArray = falsy ?? ["false", "0", "no", "off", "n", "disabled"];
1504+
if (_case !== "sensitive") {
1505+
truthyArray = truthyArray.map((v) => (typeof v === "string" ? v.toLowerCase() : v));
1506+
falsyArray = falsyArray.map((v) => (typeof v === "string" ? v.toLowerCase() : v));
1507+
}
1508+
1509+
const truthySet = new Set(truthyArray);
1510+
const falsySet = new Set(falsyArray);
15001511

15011512
const _Pipe = Classes.Pipe ?? schemas.$ZodPipe;
15021513
const _Boolean = Classes.Boolean ?? schemas.$ZodBoolean;
1503-
const _Unknown = Classes.Unknown ?? schemas.$ZodUnknown;
1514+
const _String = Classes.String ?? schemas.$ZodString;
1515+
const _Transform = Classes.Transform ?? schemas.$ZodTransform;
15041516

1505-
const inst = new _Unknown({
1506-
type: "unknown",
1507-
checks: [
1508-
{
1509-
_zod: {
1510-
check: (ctx: any) => {
1511-
if (typeof ctx.value === "string") {
1512-
let data: string = ctx.value;
1513-
if (_case !== "sensitive") data = data.toLowerCase();
1514-
if (trueValues.has(data)) {
1515-
ctx.value = true;
1516-
} else if (falseValues.has(data)) {
1517-
ctx.value = false;
1518-
} else {
1519-
ctx.issues.push({
1520-
code: "invalid_value",
1521-
expected: "stringbool",
1522-
values: [...trueValues, ...falseValues],
1523-
input: ctx.value,
1524-
inst,
1525-
});
1526-
}
1527-
} else {
1528-
ctx.issues.push({
1529-
code: "invalid_type",
1530-
expected: "string",
1531-
input: ctx.value,
1532-
});
1533-
}
1534-
},
1535-
def: {
1536-
check: "custom",
1537-
},
1538-
onattach: [],
1539-
},
1540-
},
1541-
],
1517+
const tx = new _Transform({
1518+
type: "transform",
1519+
transform: (input, payload: schemas.ParsePayload<unknown>) => {
1520+
let data: string = input as string;
1521+
if (_case !== "sensitive") data = data.toLowerCase();
1522+
if (truthySet.has(data)) {
1523+
return true;
1524+
} else if (falsySet.has(data)) {
1525+
return false;
1526+
} else {
1527+
payload.issues.push({
1528+
code: "invalid_value",
1529+
expected: "stringbool",
1530+
values: [...truthySet, ...falsySet],
1531+
input: payload.value,
1532+
inst: tx,
1533+
});
1534+
return {} as never;
1535+
}
1536+
},
1537+
error,
1538+
});
1539+
1540+
const innerPipe = new _Pipe({
1541+
type: "pipe",
1542+
in: new _String({ type: "string", error }),
1543+
out: tx,
15421544
error,
15431545
});
15441546

1545-
return new _Pipe({
1547+
const outerPipe = new _Pipe({
15461548
type: "pipe",
1547-
in: inst,
1549+
in: innerPipe,
15481550
out: new _Boolean({
15491551
type: "boolean",
15501552
error,
15511553
}),
15521554
error,
1553-
}) as any;
1555+
});
1556+
return outerPipe as any;
15541557
}

packages/zod/src/v4/mini/schemas.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,12 +1507,13 @@ export { _instanceof as instanceof };
15071507
// stringbool
15081508
export const stringbool: (
15091509
_params?: string | core.$ZodStringBoolParams
1510-
) => ZodMiniPipe<ZodMiniUnknown, ZodMiniBoolean<boolean>> = (...args) =>
1510+
) => ZodMiniPipe<ZodMiniPipe<ZodMiniString, ZodMiniTransform<boolean, string>>, ZodMiniBoolean> = (...args) =>
15111511
core._stringbool(
15121512
{
15131513
Pipe: ZodMiniPipe,
15141514
Boolean: ZodMiniBoolean,
1515-
Unknown: ZodMiniUnknown,
1515+
String: ZodMiniString,
1516+
Transform: ZodMiniTransform,
15161517
},
15171518
...args
15181519
) as any;

play.ts

Lines changed: 1 addition & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,3 @@
11
import { z } from "zod/v4";
22

3-
const MY_KEYS = ["foo", "bar", "baz"] as const;
4-
const MY_BRAND = "Label";
5-
6-
const MyKeySchema = z.literal([...MY_KEYS]).brand(MY_BRAND);
7-
const MyRecordSchema = z.record(MyKeySchema, z.number());
8-
MyKeySchema._zod.values;
9-
10-
const myRecord = MyRecordSchema.parse({
11-
// ^?
12-
foo: 1,
13-
bar: 2,
14-
baz: 3,
15-
});
16-
17-
const foo = MyKeySchema.parse("foo") as any as "foo" & z.core.$brand<"Label">;
18-
19-
// this should work, as foo has been branded
20-
const fooValue = myRecord[foo];
21-
// ^?
22-
23-
// this should be a type error, as "bar" hasn't been branded
24-
const barValue = myRecord["bar"];
25-
// ^?
26-
27-
// this should work as myRecord has been parsed but im pretty sure thats impossible in ts
28-
const bazValue = myRecord.baz;
29-
// ^?
30-
31-
type ZodSymbol<T extends z.ZodLiteral, L extends number | string | symbol> = symbol & z.core.$ZodBranded<T, L>;
32-
33-
type ZodSymbols<T extends z.core.$ZodBranded<z.ZodType, string>> = T extends z.core.$ZodBranded<infer U, infer _>
34-
? U extends z.ZodLiteral<infer Lits>
35-
? {
36-
// we create a mapped type here so we can just use [k in keyof ZodSymbols<T>] to iterate over the keys
37-
[k in Lits as k extends number | string | symbol ? ZodSymbol<U, k> : never]: never;
38-
}
39-
: never
40-
: never;
41-
42-
type MyLabelAsSymbols = ZodSymbols<typeof MyKeySchema>;
43-
// ^?
44-
45-
// this would be the output of parsing
46-
type MyRecordFixed<T extends z.ZodRecord> = T["def"]["keyType"] extends z.core.$ZodBranded<
47-
z.ZodType,
48-
number | string | symbol
49-
>
50-
? {
51-
[k in keyof ZodSymbols<T["def"]["keyType"]>]: z.infer<T["def"]["valueType"]>;
52-
}
53-
: z.infer<T>;
54-
55-
type MyRecordWithBrandedKeys = MyRecordFixed<typeof MyRecordSchema>;
56-
// ^?
57-
58-
// pretend this would be returned by MyRecordWithBrandedKeys.parse
59-
const myRecordWithBrandedKeys = {} as MyRecordWithBrandedKeys;
60-
61-
// @ts-expect-error pretend this exists
62-
const MySymbolsSchema = z.symbol(["foo", "bar", "baz"] as const).brand("Label");
63-
64-
// pretend this works and returns ZodSymbol
65-
const fooKey = MySymbolsSchema.parse("foo") as unknown as ZodSymbol<typeof MyKeySchema, "foo">;
66-
67-
// now we can use the symbol to access the record with branded keys
68-
const fooValueFixed = myRecordWithBrandedKeys[fooKey];
69-
// ^?
70-
71-
const barValueFixed = myRecordWithBrandedKeys["bar"];
72-
// ^? // should be a type error, as "bar" is not branded
73-
74-
// note: property access will not work without branded keys - one has to get the symbol first
75-
const bazValueFixed = myRecordWithBrandedKeys.baz;
3+
z;

0 commit comments

Comments
 (0)