Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
33f9c79
Add S.formData, converted to and from object schemas with S.to
claude Sep 2, 2026
2ea4b9d
Derive the checkbox from a required S.boolean form field
claude Sep 2, 2026
b00ce69
Merge origin/main into the formData branch
claude Sep 4, 2026
f953159
Fix the review findings on S.formData, and derive the empty entry
claude Sep 4, 2026
8b5eb25
Read an unchosen file input as absent, and pin the browser's own subm…
claude Sep 4, 2026
bbdb204
Omit an unchecked box, and read "1"/"0" as booleans
claude Sep 4, 2026
3ef4d50
Reject S.strict on S.formData instead of silently stripping
claude Sep 6, 2026
68ef137
Merge main, and make a blank required string field state its meaning
claude Sep 6, 2026
f6a5d59
Read a blank form entry as null for a nullable field
claude Sep 6, 2026
82c4686
Lead the blank-field rejection with "Ambiguous at", and shorten both …
claude Sep 6, 2026
7e38af2
Name the value the blank-field message is about
claude Sep 6, 2026
f83abd9
Make a boolean literal a checkbox, and a boolean list positional
claude Sep 6, 2026
ccb92f2
Record the nested-key decision, and close the nullable gap in IDEAS
claude Sep 6, 2026
7f6e9ef
Merge origin/main into the formData branch
claude Sep 6, 2026
6deb8e3
Drop the B_merge workaround main fixed, and tighten the checkbox read
claude Sep 6, 2026
683afa1
Stop a compiled operation from writing a global, and gate the class
claude Sep 6, 2026
7d663f9
Show S.formData in the README
claude Sep 6, 2026
22cb850
Cut the S.formData docs down to their examples
claude Sep 6, 2026
aae53a2
Teach the golden writer Blob, File and FormData
claude Sep 6, 2026
37ec7d1
Swap the README and js-usage FormData examples
claude Sep 6, 2026
cb968d2
Give the S.record target its own IDEAS entry
claude Sep 6, 2026
ea77685
Say what a rejected union conversion is, not which rule caught it
claude Sep 6, 2026
45cadce
Make the union rejections read like every other one
claude Sep 6, 2026
ff15689
Reject a boolean beside another type instead of reading it differently
claude Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CONTENT_CODEC_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ S.base64.with(S.to, S.string); // identity — a string is NOT bytes
| `S.base64` | bytes | sync | sync |
| `S.base64url` | bytes | sync | sync |
| `S.jsonString` (future: toon, env) | a JSON value | sync | sync |
| future: `S.formData`, protobuf | a record / a message | sync | sync |
| future: protobuf | a message | sync | sync |

`S.formData` is not a carrier on this axis: a form has no JSON document form
and no format opens into one, so a link to it has one reading or none, and a
`FormData` in a JSON position has no document, the way a `Blob` has none.

Packing bytes into a JSON position always produces base64. Packing a `File`
loses its name — the reverse builds `new File([content], "")`; a name option
Expand Down
15 changes: 6 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,15 +335,12 @@ case the harness *should* have caught or guided better — a missing check, a we
error message, a strictness gap that let a bad spec through — add a bullet here
instead of silently working around it.

- An operation whose output holds a `Blob` or `File` (`S.blob`/`S.file`
decoding, or the reverse of any conversion into them) can't be specced: the
golden writer raises "cannot represent a Blob instance as spec source code",
and an op has no way to opt out. `Uint8Array` is written as a constructor
call, but a binary container's bytes are only readable asynchronously, so the
writer would have to await the example before rendering it. It costs a whole
direction of the content axis: the `codec-*` specs for `S.blob` and `S.file`
carry codegen and error cases only, and `tests/content_test.ts` holds the
values instead.
- A `Blob`, `File` or `FormData` in an example's output renders without its
`lastModified`: that field defaults to the moment the value was built, so
recording it would rewrite the golden on every run. Two values that differ
only there therefore write the same golden. Everything else round-trips —
bytes as the text that produced them where that is printable, and as a
`Uint8Array` otherwise.
- An example's `error` is matched verbatim, so one raised by the *platform*
rather than by Sury pins that engine's wording: `new Blob([Symbol()])` says
"Cannot convert a Symbol value to a string" on Node 22 and "The argument
Expand Down
71 changes: 55 additions & 16 deletions IDEAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,22 +194,61 @@ of a form-data story. What they were built to make cheap, roughly in order:
fail with `invalid_operation`. `advanced/uint8Array.ts` is the shape to copy.
The payoff is `S.file.with(S.to, S.jsonString.with(S.to, configSchema))` —
parse an upload into a typed value, and reverse it to *build* the upload.
- **`S.formData` as a codec, not a preprocessor.** A `FormData` field is
`string | File`, so the per-field work is the existing string coercions plus
`.get`/`.getAll` extraction; the object rebuild in `advanced/json.ts`
(`jsonDecoderFn`, via `makeObjectVal`/`B_addObjectField`) is the pattern.
Reversing it emits `new FormData()` + `append` per field, which is what makes
this different from VineJS and every other form validator: one schema serves
the request handler *and* the `fetch` body. `S.urlSearchParams` is the same
code minus files, and `S.queryString` is to it what `S.jsonString` is to
`S.json`.
- **The three HTML-form quirks**, once `S.formData` exists: a checkbox is absent
when unchecked and `"on"` when checked (VineJS spells this `vine.accepted()`),
an empty text input submits `""` rather than nothing, and repeated keys are
how arrays arrive. The first wants a named `S.accepted`; the second belongs to
the codec rather than a global flag, since it's a wire quirk; the third is
`.getAll`. Bracket notation (`user[name]`) is deliberately out — VineJS leans
on `qs` for it too.
- **`S.urlSearchParams` and `S.queryString`**, now that `S.formData` has shipped.
The codec only calls `get`/`getAll`/`append`, all of which `URLSearchParams`
has, so the first is the same code minus files and the second is to it what
`S.jsonString` is to `S.json`.
- **A `S.record` target for the same readers**, for a form whose keys aren't
known ahead of time. `S.formData.with(S.to, S.record(S.string))` is rejected
today: the codec takes the object path only when `additionalItems` is
`"strip"`/`"strict"`, and a record's is the value schema, so the pair falls
through to `Can't decode FormData to { [key: string]: string; }`. Two things
to settle before it can be written:
- **What a repeated key becomes.** The declared path answers `getAll` for a
`S.array` field and `get` for every other, which a record has no field to
ask. `Object.fromEntries(fd)` keeps the last value and loses the rest;
`S.record(S.array(V))` keeps them but wraps the common case in a
one-element array. A third reading — `getAll` where the value type is an
array and `get` otherwise — matches the declared path exactly and is
probably the one.
- **Which value types can work.** Only ones a text wire can discriminate:
`S.record(S.string)` and `S.record(S.number)` are fine, and
`S.record(S.union([S.string, S.number]))` can't be — the union rules reject
`string -> string | number` before the codec is consulted, since every
entry satisfies the string arm.
- **A checkbox reading inside a union.** `S.union([S.boolean, S.number])` works
as a field, but its boolean arm is the core `string -> boolean` coercion, so
it takes `"true"`/`"false"` and not the `"on"`/`"1"`/`"0"` a box submits —
and `"0"` reads as the number, never `false`. Widening it needs a boolean
schema carrying the checkbox decoder for the codec's subtree (a
`formDataField`, internal), substituted for a boolean arm as `fromText`
builds the chain. Two things make it a decision rather than a patch:
- **`"1"` belongs to both readings.** A box submits it for "checked" and a
number field submits it for one, and no rule can have both. That is the
same shape as the blank-string question, where the answer was to make the
schema say. `S.string.with(S.to, S.union([S.boolean, S.number]), {decode,
encode})` already says it in four lines, which may be the whole answer.
- **Omitting `false` doesn't round-trip.** A checkbox field encodes `false`
as no entry, so the union arm would want the same. But a missing entry
then has to read back as `false`, and inside a union it can't: absence is
equally "the number wasn't sent". Only `S.optional(S.union([…]))` could
carry it, and there absence already means `undefined`.
- **`string -> string | undefined` is still rejected by the union rules**, so
the env pattern can't read an optional string field — where the form codec
converts the present arm itself. Pinned by
`specs/dict-to-object-optional-string`.
- **Nested keys for `S.formData`, with no API to turn them on.** Nesting the
schema is the switch: `S.schema({ user: S.schema({ city }) })` rejects the
pair today, and instead should read `user[city]`. Brackets only — PHP
invented the spelling, Rails, `qs` and Express read it, and a plain `<form>`
can produce it without JS. Dot notation stays out: it is the newer JS-side
convention, and accepting both means two `get` calls per leaf for a spelling
no browser emits on its own. The point of driving the key off the schema is
that none of `qs`'s hazards arrive with it — no depth or parameter limit, no
`__proto__` filtering, no array-vs-object heuristic — because the shape is
known before a document is read, and a flat schema never probes at all. If a
second spelling is ever wanted on the wire out, it is a second constant
(`S.formDataNested`), not a config object on the first.
- **`S.mime`** for uploads, next to the size bounds. Wants a JSON Schema emit
(`contentMediaType`, and `format: "binary"` for the instances) — which is the
point at which `minSize`/`maxSize` should be revisited, since neither has a
Expand Down
112 changes: 112 additions & 0 deletions docs/js-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
- [Instance](#instance)
- [Blob](#blob)
- [File](#file)
- [FormData](#formdata)
- [Checkboxes](#checkboxes)
- [Blank inputs](#blank-inputs)
- [Not supported](#not-supported)
- [Content](#content)
- [Meta](#meta)
- [Brand](#brand)
Expand Down Expand Up @@ -1165,6 +1169,114 @@ its own:
const upload = (f: S.File) => S.parser(S.file)(f);
```

## FormData

`S.formData` validates a `FormData`. Convert it with `S.to` and one schema
serves both the request handler and the `fetch` body:

```ts
const signup = S.formData.with(
S.to,
S.schema({
email: S.email,
age: S.number, // "42" -> 42
agree: true, // a checkbox that has to be ticked
avatar: S.file,
}),
);

S.decoder(signup)(await request.formData());
// => { email: "a@b.co", age: 42, agree: true, avatar: File }
S.encoder(signup)(user);
// => a FormData, ready for fetch(url, { body })
```

A field reads its entry as text through the same coercions
[`S.record(S.string)`](#records) gets; `S.file` and `S.blob` take the entry as
it is, and `S.array` reads every entry of the key — `S.array(S.file)` included,
for a multi-file input.

### Checkboxes

A boolean field is a checkbox, since nothing else a browser sends is one, and
it stays one however you wrap it:

```ts
S.schema({
agree: S.boolean, // "on"/"true"/"1" -> true, "false"/"0" or absent -> false
terms: true, // must be ticked: absent -> Expected true, received false
spam: false, // must stay clear: "on" -> Expected false, received true
notify: S.optional(S.boolean), // tri-state: absent -> undefined
seen: S.nullable(S.boolean), // absent -> null
});
```

Encoding omits an unchecked box, exactly as a browser does. `S.optional(S.boolean)`
is the exception — absent and unchecked are the same wire, so its `false` is
written out to keep the third state apart, and `S.optional(S.boolean, true)`
therefore cannot round-trip.

Any other `value` is a string the schema should name (`S.union(["yes", "no"])`),
and `S.array(S.boolean)` is a positional list, not a checkbox group — a group
submits the value of each checked box, which is `S.array(S.string)`.

A boolean beside another type is rejected: only a whole field can be a
checkbox, so `S.union([S.boolean, S.number])` would read `"on"` as text rather
than as a tick. Spell the reading out instead:

```ts
S.string.with(S.to, S.union([S.boolean, S.number]), {
decode: (t) => (t === "on" || t === "true" ? true : t === "false" ? false : +t),
encode: (v) => (typeof v === "boolean" ? (v ? "on" : "false") : "" + v),
});
```

### Blank inputs

An empty text input submits `""`, and a required string field has to say what
that means:

```ts
S.formData.with(S.to, S.schema({ name: S.string }));
// throws at S.decoder: Ambiguous at name: say what "" means with
// S.nonEmpty, S.minLength(0), S.optional or S.nullable
```

```ts
S.schema({
name: S.string.with(S.nonEmpty), // "" -> Expected string.length >= 1
bio: S.string.with(S.minLength, 0), // "" -> "", a value
nick: S.optional(S.string), // "" -> undefined
note: S.nullable(S.string), // "" -> null
tier: S.optional(S.number, 1), // "" -> 1, the default
age: S.number, // "" -> Expected number
});
```

Only a required, non-nullable string has to choose — every other target answers
for itself, `S.minLength(0)` being the way to say "the empty string is a value"
without adding a check.

### Not supported

`S.strict` fails at operation creation: a browser adds entries no schema
declared, so "no entries but these" is not something a form can promise.
Objects strip by default; keep it that way.

Nested objects have no wire form here — send them as a
[`S.jsonString`](#advanced-schemas) field:

```ts
S.schema({ prefs: S.jsonString.with(S.to, S.schema({ theme: S.string })) });
```

A file input with nothing chosen still submits an empty, unnamed `File`; that
sentinel reads as absent, so a required `S.file` reports a missing file.

Both directions are sync — nothing reads a file's bytes. `S.FormData` is
exported as a type for projects with neither `lib.dom` nor `@types/node`, like
[`S.File`](#file).

## Content

Bytes in JSON become base64. They are not mangled as UTF-8.
Expand Down
29 changes: 29 additions & 0 deletions docs/rescript-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- [`instance`](#instance)
- [`blob`](#blob)
- [`file`](#file)
- [`formData`](#formdata)
- [`json`](#json)
- [`jsonString`](#jsonstring)
- [Content](#content)
Expand Down Expand Up @@ -1265,6 +1266,34 @@ let schema = S.file->S.maxSize(1_000_000)
A `File` is a `Blob`, so it also satisfies [`S.blob`](#blob) — not the other way
round. It takes the same size bounds.

### **`formData`**

`S.t<S.formData>`

```rescript
let schema = S.formData->S.to(
S.schema(s => {
name: s.field("name", S.string->S.nonEmpty),
age: s.field("age", S.int), // "42" -> 42
agree: s.field("agree", S.literal(true)), // a checkbox that has to be ticked
notify: s.field("notify", S.bool), // "on" -> true, absent -> false
tags: s.field("tags", S.array(S.string)), // every "tags" entry
avatar: s.field("avatar", S.file),
}),
)

%raw(`new FormData()`)->S.parseOrThrow(~to=schema) // throws - Failed at name: Expected string, received undefined
value->S.reverseConvertOrThrow(~from=schema) // a FormData with one append per field
```

A field reads its entry as text through the same coercions `S.dict(S.string)`
gets; `S.file` and `S.blob` take the entry as it is, and an encode omits an
unchecked box the way a browser does. A required, non-nullable string must say
what a blank entry means — `S.string->S.nonEmpty`, `S.string->S.minLength(0)`,
`S.option` or `S.null` — or the operation fails to build. The type is abstract,
since the stdlib has no `FormData` module; a value from a fetch binding is cast
to it.

### **`json`**

`S.t<JSON.t>`
Expand Down
Loading
Loading