Implemented. CODEC_SPEC.md governs built-in conversions and custom coders.
This file governs the pairs where two built-in readings exist: a carrier (a
value that stores other data) meeting a format that could either rewrite it or
open it.
B_contentDiffers and B_readsPayload in src/builder.ts are the two decisions
below; the conversions themselves live on the carriers
(src/advanced/uint8Array.ts, src/advanced/file.ts, S.base64 in
src/refinements.ts). docs/js-usage.md carries the user-facing version under
"Content", and the packages/sury/specs/ rows listed at the end snapshot the
behavior.
S.uint8Array.with(S.to, S.jsonString);
// pack: base64 β '"ZGF0YQ=="' the bytes are a value IN the document
// unpack: UTF-8 + JSON.parse the bytes ARE the documentBoth are useful. Today the pair silently does a third, broken thing β UTF-8
escape, which corrupts any byte outside ASCII ([137, 80, 78, 71] β "οΏ½PNG" β
decodes back as different bytes, no error at either end).
Two values join CODEC_SPEC.md's Conversion union:
type Conversion<A, B> = Coder<A, B> | "auto" | "never" | "pack" | "unpack" | { async: Coder<A, Promise<B>> };Each names what its direction does to its own source:
"unpack"β open the source, hand its payload to the target."pack"β store the source's value inside the target.
// the bytes ARE the JSON text, rather than a value stored in one
S.uint8Array.with(S.to, S.jsonString, { decode: "unpack", encode: "pack" });
// Schema<Uint8Array, string>
// base64 inside a JSON string, into a File
S.jsonString.with(S.to, S.file, { decode: "unpack", encode: "pack" });A slot picks a reading; it does not invent a conversion. What the opened payload
then converts to is still whatever the rules below give, so a target that no
built-in conversion reaches is still rejected β reading a File into an object
schema needs the format that turns its text into one
(S.file.with(S.to, S.jsonString.with(S.to, configSchema)), rule 3), not a slot.
Mixing with "never", coders and {async} follows CODEC_SPEC.md β each of
those answers for its own direction. "auto" does not: it leaves that direction
asking the question the reading just answered, so a reading has to be paired
with its opposite. "unpack" opposite "unpack" (or "pack" opposite "pack")
is rejected for the mirror reason β opening in both directions leaves no side
holding the payload.
A bare "pack" or "unpack" as the third argument names the decode reading
and takes the opposite on encode β readings must come as a pair, so one word
is enough. A bare function still means decode-only (custom rule 3).
S.uint8Array.with(S.to, S.jsonString, "unpack");
// β‘ { decode: "unpack", encode: "pack" }See above. Everything below is what happens when no slot is written.
A field or array item is a value in the document β nothing asks it to be a document of its own:
S.encoder(S.schema({ payload: S.uint8Array }), S.jsonString)({ payload: bytes });
// {"payload":"ZGF0YQ=="} base64, not mangled UTF-8A Blob/File field packs the same way, which makes that encode async (a
Blob's bytes are only readable asynchronously) β S.asyncEncoder, and the sync
S.encoder fails at creation like any other async operation.
A field that should hold a nested document says so, and rule 3 takes over:
S.jsonString.with(S.to, S.schema({
id: S.string,
meta: S.jsonString.with(S.to, tagsSchema), // the field's string IS a document
}));
// decode: meta is parsed, not re-escaped; encode: serialized back into a stringWriting what's inside is how you say "open it". When the format names its
payload with .to, a carrier feeding it unpacks:
S.file.with(S.to, S.jsonString.with(S.to, configSchema)); // read + parse
S.base64url.with(S.to, S.jsonString.with(S.to, claimsSchema)); // a JWT segmentThe check is on the schema shape (a content marker plus .to present), so it
is local and stable: new built-in conversions in later versions can never
retract it.
The payload goes inside, not after. Each link settles when it is written, so
S.file.with(S.to, S.jsonString).with(S.to, configSchema) is rule 4 on its first
link β at that point nothing has said what the JSON string carries, and the
second link can't reach back, because reversal would have lost the distinction
anyway (see the implementation notes). Write the payload inside the format, or
say which reading you meant with a slot.
S.file.with(S.to, S.jsonString); // which reading?Where no slot would resolve it either β a union, or S.json β the pair says it
has no decoder instead of naming a spelling that wouldn't work:
S.uint8Array.with(S.to, S.json); // Can't decode Uint8Array to JSON
S.uint8Array.with(S.to, S.optional(S.jsonString));Both fail at operation creation (like every conversion error), one message built from the same pieces the existing unsupported-conversion error uses:
Ambiguous conversion from File to JSON string. Use S.to(from, to, "unpack" | "pack")
There are no per-carrier defaults. When both readings are live, the library asks.
These skip the rules entirely:
S.jsonString.with(S.to, S.jsonString); // identity (same schema)
S.email.with(S.to, S.string); // widen β a constraint format has no payload
S.base64.with(S.to, S.string); // widen β its payload is bytes, not a string
S.base64.with(S.to, S.uint8Array); // payload transfer β one reading
S.file.with(S.to, S.string); // payload transfer (text read, async)
S.uint8Array.with(S.to, S.string); // UTF-8 β pack and unpack produce the SAME code
S.jsonString.with(S.to, userSchema); // parse β a plain type can't be packed into
S.number.with(S.to, S.jsonString); // pack β a number has nothing to openThe two-string contrast is the one thing to memorize, and it follows from the payloads:
S.jsonString.with(S.to, S.string); // parses β a string IS a JSON value
S.base64.with(S.to, S.string); // identity β a string is NOT bytes| carrier | payload | read | write |
|---|---|---|---|
S.blob, S.file |
bytes and text | async | sync |
S.uint8Array |
bytes | sync | sync |
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 |
Packing bytes into a JSON position always produces base64. Packing a File
loses its name β the reverse builds new File([content], ""); a name option
belongs on S.file itself, not on a slot.
pack and unpack trade places under S.reverse, like parser/serializer:
{decode: "unpack", encode: "pack"} reversed is {decode: "pack", encode: "unpack"}, and double reversal restores both. Async rides the carrier:
a File unpack is an async decode, its reverse an async encode.
Derived from the link β nothing declared twice. toJSONSchema describes a
schema's input, so the annotations land on whichever side of a link is the
string; a Blob input has no document, and keeps saying so.
| schema | draft-07 / 2020-12 | openapi-3.0 |
|---|---|---|
S.base64 |
contentEncoding: "base64" |
format: "byte" |
S.base64url |
contentEncoding: "base64url" |
format: "base64url" |
S.jsonString.with(S.to, X) |
contentMediaType: "application/json", plus contentSchema: <X> in 2020-12 |
bare string |
S.string.with(S.to, S.blob) |
contentMediaType: "application/octet-stream" |
format: "binary" |
S.jsonString.with(S.to, S.file, {decode: "unpack", ...}) |
contentMediaType: "application/json" |
format: "binary" |
S.base64.with(S.to, S.file) |
both of the above | format: "byte" |
A carrier's own emit fills only what the string hasn't already said about
itself, which is why the last two rows keep the encoding they are written in
rather than the medium they end in. The base64 rows round-trip through
fromJSONSchema, from either spelling; a contentMediaType does not yet, and
comes back as a plain string. The
contentSchema emit is gated on a json-format source, so a base64 segment
carrying a document annotates the encoding it is stored in and stops there.
The axis stops at a union, and at S.json. A union carries neither content
nor .to of its own, and neither an arm's payload declaration nor a reading
written on the union reaches the dispatch; S.json is the document rather than
a rendering of one, so neither reading of a link to it is built. Both are read
far enough to know the pair is not a plain transfer, and then rejected as having
no decoder β which a custom coder still answers. Rule 2 is the exception on both
counts, and only because jsonEncoderFn rewrites the arms itself and reads
content for the shape a document stores its target as.
Not yet: a packed bytes field has no document form β S.toJSONSchema of
S.schema({payload: S.uint8Array}) reports Expected JSON, received Uint8Array
rather than describing the base64 string the field becomes. That follows the
existing rule that a carrier has no document of its own (S.blob answers the
same way); making content answer it instead is a separate change.
Resolution is syntactic, and it happens where the link is written. Two schema fields carry it:
| field | meaning |
|---|---|
content |
the schema this value's payload is stored as inside a JSON document β S.base64 for every bytes carrier, S.json for S.jsonString and S.json itself. Absent means the value carries no payload. |
opens |
the reading a "pack"/"unpack" slot wrote, on the schema that direction converts into β so reverse, which copies node by node, carries each direction's slot with it. |
Two schemas that agree on content carry the same kind of payload, so a link
between them is a plain transfer; two that disagree have both readings live, and
B_readsPayload answers which one applies: the slot if there is one, otherwise
the target naming its own payload with .to (rule 3).
B_contentDiffers asks rule 4's question at the two places a .to link is made
β codecTo for a written S.to, getDecoder for an operation given its own
target β and the empty direction takes a slot that rejects the operation. It
cannot be left to compile time, because reversing a chain turns the target's
payload declaration into just another link: the legal X -> jsonString -> File
and the rejected jsonString -> File reach the decoder as the same pair.
What the rejection says is the caller's, not the question's. codecTo names
the slots, because the caller has somewhere to write one; getDecoder's form
has nowhere, so it reports the pair as having no decoder β which a coder still
answers. And codecTo says the same for a pair no slot resolves: a union arm's
payload and a reading written on the union both stop short of the dispatch, and
S.json has no opened form of its own.
Everything else is the two markers being read where a decision already happened:
rule 2 is the carrier's encoder being handed a content-format target,
jsonEncoderFn's fallback uses content for the shape a document stores the
target as instead of assuming string, and the nested-jsonString fix is
B_narrowJsonSourcedJsonString no longer stopping at a field whose jsonString
declares a payload. No new Val fields, no compile-loop cost, nothing in
generated code a hand-written converter wouldn't contain.
The one price is the universal path: getDecoder is in every bundle, so
B_contentDiffers is too, and copySchema and reverse each carry a line for
the two markers. About 180 gzipped bytes on every export (bundleSize.yaml,
where the smallest go 4136 β 4315) β the question and one closure; the messages
ride with the callers, and so does the union walk B_contentNode does, which is
why only codecTo pays for it. That buys a creation-time gate on conversions
that otherwise corrupt data silently.
Conversions live on the carrier, not the format β the existing S.date
pattern. S.uint8Array's encoder owns base64; S.file's owns .text() /
.arrayBuffer(); S.base64 owns opening itself into text. So S.jsonString
never references a base64 helper, and a bundle that never mentions bytes never
ships one. The format side checks only the generic content marker β it never
names toon, env, or any other format, which is what keeps each future carrier a
self-contained file.
S.fromJSONSchema pays for the round trip: reading contentEncoding back means
naming S.base64 and S.base64url, which bring their patterns and conversions.
That is the largest row in bundleSize.yaml. Every format costs it something;
these are the first that carry a codec.
The keyword is an annotation in both dialects, so a validator that reads it as
one accepts "hello world" under contentEncoding: "base64". Sury reads it as
a schema, because format β an annotation on the same terms β has always come
back as the validating S.uuid, S.email and the rest. A round trip through
fromJSONSchema produces the schema the keyword names, not a validator for the
document it came from.
The base64 helpers feature-detect native Uint8Array methods, then Node
Buffer, then btoa/atob, inside a @__PURE__ factory per alphabet so a
globalThis.Buffer read cannot leak into unrelated exports. Generated code is
a single e[N](i) call either way. The atob fallback bridges through an
intermediate binary string in 8KB chunks. The whole array blows
String.fromCharCode's argument limit, and a byte at a time is several times
slower than either. scenarios.yaml's base64-pack / base64-unpack are what
that costs a consumer. Carriers pack through a content node that has the format
refine and bc only, so a File bundle does not ship recode or TextEncoder.
| today | after |
|---|---|
S.uint8Array.with(S.to, S.jsonString) β UTF-8 escape (corrupts non-ASCII) |
rule 4 error |
{payload: S.uint8Array} in a JSON document β corrupts |
base64 |
S.encoder(S.uint8Array.with(S.to, S.number))(42) β returns 42 typed as Uint8Array |
error (the decoder's missing fall-through, a standalone soundness fix) |
S.optional(S.string).with(S.to, S.uint8Array) β the undefined arm passed through as bytes |
error, which CODEC_SPEC.md's rule 3 already said: a variant with no decoder rejects the operation |
S.base64.with(S.trim).with(S.to, S.uint8Array) β packed the base64 text as bytes |
the payload, same as untrimmed: a refinement that only reshapes the text carries the marker |
a S.jsonString.with(S.to, X) field of a decoded document β re-escaped its own text, then failed against X |
parsed (rule 3) |
a noValidation field of a JSON document β Can't decode JSON to Date |
decoded: noValidation drops the checks, not the conversion. Only S.json itself still travels as text, since its parse is its check |
every Blob/File conversion β creation error |
its payload conversions work, and the rest asks. S.file.with(S.to, S.blob) widens; the other way round is still an error, because not every blob is a file |
| β | a union target of a Blob/File stays an error, where a S.uint8Array one works: a union picks its variant before the read resolves, so the arm's checks would run against the promise |
All land together. After this release, changes only turn errors into working code.
One spec per carrier kind Γ direction, plus one per format's own codec β never per pair. Bytes carriers use non-ASCII fixtures in both directions; ASCII-only fixtures are what hid the corruption above.
| spec | what it pins |
|---|---|
base64, base64url, uint8array |
the carriers themselves, and each alphabet's JSON Schema emit |
codec-uint8array-base64, codec-uint8array-base64url, codec-base64-uint8array |
the bytes payload transfer, both alphabets and both ways round |
codec-base64-base64url |
recode between alphabets |
codec-base64-trim-base64url |
recode after S.trim, which copies content but not bc |
codec-uint8array-jsonstring-pack |
S.to(from, to, "pack") as the opposite pair |
codec-base64-string vs codec-jsonstring-string |
the payload rule's least guessable pair β widen vs parse |
jsonstring-object-url, codec-array-never-jsonstring |
the two shapes that reach jsonString's fallback for a value it can't serialize piecewise |
codec-base64-file |
payload transfer in and out of a binary container |
codec-uint8array-jsonstring-ambiguous, codec-file-jsonstring-ambiguous, codec-base64-jsonstring-ambiguous |
rule 4, one per carrier kind |
codec-uint8array-json-unsupported, codec-uint8array-optional-jsonstring-unsupported, codec-file-optional-email-unsupported |
where the axis stops β S.json, a union carrying a payload, and any union at all opposite a container whose read is asynchronous |
codec-file-blob |
the one instance widening the axis makes legal, and the direction that stays an error |
codec-base64-jsonstring-payload, codec-base64url-jsonstring-payload |
rule 3, both alphabets β the base64url one is the JWT segment |
codec-jsonstring-object-uint8array, codec-jsonstring-object-file |
rule 2, both directions |
codec-jsonstring-object-optional-uint8array, jsonstring-optional-base64-field |
rule 2 through a union arm β the one that needs a hop to the stored form, and the one that already is it |
codec-jsonstring-object-optional-file, codec-jsonstring-dict-file |
the two things an async encode broke: an object's optional fields, and a dict with no keys |
jsonstring-novalidation-base64 |
noValidation voids the raw-splice proof for a content format too |
codec-jsonstring-object-jsonstring, codec-jsonstring-jsonstring-payload |
the nested-document field, and the document that carries one directly |
codec-email-string, codec-jsonstring-object-optional-jsonstring, jsonstring-optional-jsonstring-field, jsonstring-novalidation-date |
bugs this work turned up and didn't cause, each carrying a FIXME that says so |
codec-uint8array-jsonstring-packed, codec-jsonstring-file-slots |
rule 1, both spellings of the pair |
codec-uint8array-number-unsupported, codec-optional-string-uint8array-unsupported |
the decoder fall-through the soundness fix added, standalone and through a union arm |
codec-array-never-uint8array |
the one source the fall-through has to keep letting through: nothing reaches a never, so there is no conversion to reject |
string-to-blob |
the conversion that used to be two creation errors |
codec-base64-trim-jsonstring-ambiguous |
the marker surviving a S.trim link, so the pair still reports rather than guesses |
jsonstring-novalidation-date, jsonstring-novalidation-format |
a noValidation field decoded back out of its document, and jsonstring-novalidation for the JSON target that still travels as text |
codec-uint8array-string, codec-uint8array-string-novalidation, codec-string-novalidation-uint8array, codec-email-uint8array |
the UTF-8 hop both ways β lossy for bytes that aren't text, and checked by the string target the bytes spell, unless noValidation says not to |
codec-uint8array-jsonstring-payload |
rule 3 into a payload the caller reads as bytes |
codec-uint8array-jsonstring-unpack |
the "unpack"/"pack" spelling whose encode side lands on bytes |
codec-json-object-uint8array |
rule 2 in a JSON value, where the reading is the same as in JSON text |
codec-jsonstring-object-text-uint8array, codec-jsonstring-object-optional-text-uint8array |
rule 2 through a field storing the same payload as the source β text stays text, with or without the union arm S.optional adds |
codec-uint8array-optional-base64, codec-uint8array-optional-string |
a payload arm keeping its marker through the union narrow, and an arm with no payload still taking the text |
codec-base64-trim-uint8array |
the marker surviving a S.trim link into bytes |
tests/content_test.ts holds the rest, and only because the spec format can't:
a golden can't hold a Blob or File, and every compiled operation must run
an example β so a conversion that only ever produces one has no spec to live
in. That covers S.file to and from bytes and text, rule 2 with a File in
the value position, rule 3 into a payload read out of a file, and the
"unpack"/"pack" spelling whose decode side lands in one. It also holds
every reading the API rejects outright β a pair that agrees on the payload, a
side that carries none, S.json, and two readings naming the same direction β
since those panic while the schema is being built and ts.schema never
evaluates. CONTRIBUTING.md's Spec Harness Suggestions is where the fix belongs;
when it lands, those rows move back.