Skip to content

Commit f56cc55

Browse files
committed
chore: add zod stringbool support (squashed)
1 parent 46e6cee commit f56cc55

File tree

7 files changed

+382
-3
lines changed

7 files changed

+382
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added support for Zod 4 [stringbools](https://zod.dev/api?id=stringbool). [#610](https://github.com/ciscoheat/sveltekit-superforms/issues/610)
13+
1014
### Changed
1115

1216
- TypeBox adapter has been bumped to 1.0! Check the [migration guide](https://github.com/sinclairzx81/typebox/blob/main/changelog/1.0.0-migration.md) to upgrade. Note that if you need to stay on 0.x for a while, you cannot upgrade to this version of Superforms.

STRINGBOOL_SUPPORT.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Zod v4 `stringbool()` Support
2+
3+
Superforms now supports Zod v4's `z.stringbool()` type, which allows strict validation of boolean string values.
4+
5+
## What is `z.stringbool()`?
6+
7+
`z.stringbool()` is a Zod v4 feature that validates string inputs and converts them to booleans, but only accepts specific truthy/falsy string values. Unlike `z.boolean()` or `z.coerce.boolean()`, which accept any non-"false" value as truthy, `stringbool()` enforces strict validation.
8+
9+
## Implementation Details
10+
11+
Internally, `z.stringbool()` is implemented as a pipe: `string -> transform -> boolean`
12+
13+
Superforms detects this pattern in the JSON Schema generation phase and:
14+
15+
1. Marks the field with `format: "stringbool"` in the JSON Schema
16+
2. Keeps the value as a string during FormData parsing
17+
3. Lets Zod perform the validation and transformation
18+
19+
## Usage Examples
20+
21+
### Basic Example
22+
23+
```typescript
24+
import { z } from 'zod/v4';
25+
import { superValidate } from 'sveltekit-superforms';
26+
import { zod } from 'sveltekit-superforms/adapters';
27+
28+
const schema = z.object({
29+
acceptTerms: z.stringbool({
30+
truthy: ['true'],
31+
falsy: ['false'],
32+
case: 'sensitive'
33+
})
34+
});
35+
36+
// Server-side (+page.server.ts)
37+
export const actions = {
38+
default: async ({ request }) => {
39+
const form = await superValidate(request, zod(schema));
40+
41+
if (!form.valid) {
42+
return fail(400, { form });
43+
}
44+
45+
// form.data.acceptTerms is now a boolean (true or false)
46+
console.log(form.data.acceptTerms); // boolean
47+
48+
return { form };
49+
}
50+
};
51+
```
52+
53+
### Client-side Form
54+
55+
```svelte
56+
<script lang="ts">
57+
import { superForm } from 'sveltekit-superforms';
58+
59+
export let data;
60+
61+
const { form, errors, enhance } = superForm(data.form);
62+
</script>
63+
64+
<form method="POST" use:enhance>
65+
<input type="hidden" name="acceptTerms" value="true" />
66+
<button type="submit">Accept Terms</button>
67+
</form>
68+
```
69+
70+
### Custom Truthy/Falsy Values
71+
72+
You can customize which string values are considered truthy or falsy:
73+
74+
```typescript
75+
const schema = z.object({
76+
status: z.stringbool({
77+
truthy: ['yes', 'on', '1'],
78+
falsy: ['no', 'off', '0'],
79+
case: 'insensitive' // Case-insensitive matching
80+
})
81+
});
82+
```
83+
84+
With this schema:
85+
86+
- `"yes"`, `"YES"`, `"on"`, `"ON"`, `"1"``true`
87+
- `"no"`, `"NO"`, `"off"`, `"OFF"`, `"0"``false`
88+
- Any other value → Validation error
89+
90+
## Benefits Over Regular Boolean
91+
92+
| Feature | `z.boolean()` / `z.coerce.boolean()` | `z.stringbool()` |
93+
| --------------- | ------------------------------------------ | ------------------------------------------------ |
94+
| Validation | Accepts any value as truthy except "false" | Only accepts specified truthy/falsy values |
95+
| Type Safety | Less strict | More strict |
96+
| Error Detection | May miss invalid inputs | Catches invalid inputs |
97+
| Use Case | General boolean fields | Security-critical or strict validation scenarios |
98+
99+
## Common Use Cases
100+
101+
### Toggle Role Buttons
102+
103+
```typescript
104+
const schema = z.object({
105+
role: z.string(),
106+
hadRole: z.stringbool({
107+
truthy: ['true'],
108+
falsy: ['false'],
109+
case: 'sensitive'
110+
})
111+
});
112+
```
113+
114+
Hidden form fields that track whether a user had a role when the page loaded, preventing unexpected toggles.
115+
116+
### Checkbox-like Hidden Inputs
117+
118+
```typescript
119+
const schema = z.object({
120+
consent: z.stringbool({
121+
truthy: ['true'],
122+
falsy: ['false']
123+
})
124+
});
125+
```
126+
127+
For forms that look like buttons to the user but need to track boolean state.
128+
129+
### Feature Flags
130+
131+
```typescript
132+
const schema = z.object({
133+
enabled: z.stringbool({
134+
truthy: ['enabled', 'on'],
135+
falsy: ['disabled', 'off'],
136+
case: 'insensitive'
137+
})
138+
});
139+
```
140+
141+
## Testing
142+
143+
Run the stringbool tests:
144+
145+
```bash
146+
npm test -- stringbool
147+
```
148+
149+
## Related Issues
150+
151+
- [Issue #610](https://github.com/ciscoheat/sveltekit-superforms/issues/610) - Original feature request
152+
- [Zod Issue #4821](https://github.com/colinhacks/zod/issues/4821) - Discussion about detecting stringbool in JSON Schema
153+
154+
## Credits
155+
156+
Thanks to [@fr33bits](https://github.com/fr33bits) for reporting the issue and [@colinhacks](https://github.com/colinhacks) for clarifying the stringbool implementation.

src/lib/adapters/zod4.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,50 @@ const defaultJSONSchemaOptions = {
4949
} else if (def.type === 'bigint') {
5050
ctx.jsonSchema.type = 'string';
5151
ctx.jsonSchema.format = 'bigint';
52+
} else if (def.type === 'pipe') {
53+
// Handle z.stringbool() - it's a pipe from string->transform->boolean
54+
// Colin Hacks explained: stringbool is just string -> transform -> boolean
55+
// When io:'input', we see the string schema; when io:'output', we see boolean
56+
const pipeDef = def as typeof def & { in: any; out: any };
57+
const inSchema = pipeDef.in;
58+
const outSchema = pipeDef.out;
59+
60+
// Check if it's: string -> (transform or pipe) -> boolean
61+
if (inSchema?._zod?.def.type === 'string') {
62+
// Traverse through the output side (right) to find if it ends in boolean
63+
let currentSchema = outSchema;
64+
let isStringBool = false;
65+
66+
// Traverse through transforms and pipes to find boolean
67+
while (currentSchema?._zod?.def) {
68+
const currentDef = currentSchema._zod.def;
69+
if (currentDef.type === 'boolean') {
70+
isStringBool = true;
71+
break;
72+
} else if (currentDef.type === 'transform') {
73+
// Transform doesn't have a nested schema, but we can't traverse further
74+
// Check if the transform is inside another pipe
75+
break;
76+
} else if (currentDef.type === 'pipe') {
77+
// Continue traversing the pipe
78+
const nestedPipeDef = currentDef as typeof currentDef & { out: any };
79+
currentSchema = nestedPipeDef.out;
80+
} else {
81+
break;
82+
}
83+
}
84+
85+
// Also check if outSchema directly is boolean
86+
if (!isStringBool && outSchema?._zod?.def.type === 'boolean') {
87+
isStringBool = true;
88+
}
89+
90+
if (isStringBool) {
91+
// Mark as stringbool so FormData parser knows to handle it as string
92+
ctx.jsonSchema.type = 'string';
93+
ctx.jsonSchema.format = 'stringbool';
94+
}
95+
}
5296
} else if (def.type === 'set') {
5397
// Handle z.set() - convert to array with uniqueItems
5498
ctx.jsonSchema.type = 'array';

src/lib/formData.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ function parseFormDataEntry(
363363
return parseFloat(value ?? '');
364364
case 'boolean':
365365
return Boolean(value == 'false' ? '' : value).valueOf();
366+
case 'stringbool':
367+
// Zod's z.stringbool() - keep as string, let Zod validate it
368+
// This prevents Superforms from coercing to boolean before Zod can validate
369+
return value;
366370
case 'unix-time': {
367371
// Must return undefined for invalid dates due to https://github.com/Rich-Harris/devalue/issues/51
368372
const date = new Date(value ?? '');

src/lib/jsonSchema/schemaDefaults.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@ export function defaultValue(type: SchemaType, enumType: unknown[] | undefined):
217217
case 'int64':
218218
case 'bigint':
219219
return BigInt(0);
220+
case 'stringbool':
221+
// For stringbool, return empty string - let Zod validation handle it
222+
// The schema should define a default if one is needed
223+
return '';
220224
case 'set':
221225
return new Set();
222226
case 'map':
@@ -226,7 +230,6 @@ export function defaultValue(type: SchemaType, enumType: unknown[] | undefined):
226230
case 'undefined':
227231
case 'any':
228232
return undefined;
229-
230233
default:
231234
throw new SchemaError(
232235
'Schema type or format not supported, requires explicit default value: ' + type

src/lib/jsonSchema/schemaInfo.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export type SchemaType =
1414
| 'set'
1515
| 'map'
1616
| 'null'
17-
| 'undefined';
17+
| 'undefined'
18+
| 'stringbool';
1819

1920
export type SchemaInfo = {
2021
types: Exclude<SchemaType, 'null'>[];
@@ -28,7 +29,16 @@ export type SchemaInfo = {
2829
required?: string[];
2930
};
3031

31-
const conversionFormatTypes = ['unix-time', 'bigint', 'any', 'symbol', 'set', 'map', 'int64'];
32+
const conversionFormatTypes = [
33+
'unix-time',
34+
'bigint',
35+
'any',
36+
'symbol',
37+
'set',
38+
'map',
39+
'int64',
40+
'stringbool'
41+
];
3242

3343
/**
3444
* Normalizes the different kind of schema variations (anyOf, union, const null, etc)
@@ -131,6 +141,13 @@ function schemaTypes(
131141
const i = types.findIndex((t) => t == 'string');
132142
types.splice(i, 1);
133143
}
144+
145+
// For stringbool, remove the string type, as the schema format will be used
146+
// stringbool should be treated as a special string that validates to boolean
147+
if (schema.format == 'stringbool') {
148+
const i = types.findIndex((t) => t == 'string');
149+
if (i !== -1) types.splice(i, 1);
150+
}
134151
}
135152

136153
if (schema.const && schema.const !== null && typeof schema.const !== 'function') {

0 commit comments

Comments
 (0)