Ideas draft
August 4, 2026 · View on GitHub
v11
ideas
- Add
promisetype andS.promise(instead of async flag internally) - Async output refiner runs on the Promise wrapper, not the resolved value.
When a decoder result is async (e.g. a union with an async member) and the
schema has a user output refiner,
B_markOutputemits the checks against the Promise var instead of inside.then()on the resolved value. Fix must run the output checks inside the async continuation without adding the ~40 bytes per-schema the naive fix cost (B_markOutput is on every schema's hot path).
TODO:
Test null<> in ppx
// Test that refinement works correctly with reverse
S.reverse(S.schema({
foo: S.string->S.to(S.number)
})->S.refine(value => value.foo > 0))
TS operation functions
-
rename
serializerto reverse parser ? -
Make
foo->S.to(S.unknown)stricter ?? -
Add
S.to(from, target, parser, serializer)instead ofS.transform? -
Make built-in refinements not work with
unknown. UseS.to(manually & automatically) to deside the type first -
Better inline empty recursive schema operations (union convert)
-
Don't iterate over JSON value when it's
S.jsonconvert without parsing -
Add
S.date.with(S.migrationFrom, S.string, <optionalParser>). -
Allow to pass {} instead of S.schema({}) to S.array and other schemas
Final release fixes
- Add
S.envto support coercion for union items separately. Likerescript-envsafeused to do withpreprocess - Make
S.recordaccept two args - Update docs
Numeric bounds follow-ups
-
Move bound checks off
refinerinto the decoder.S.gt/S.ltbuild a refinement whose check duplicates what the decoder could emit from the bound fields directly, soS.int32.with(S.gte, 5)range-checks twice. Deriving them innumberDecoderfuses the two and drops a check per bound. Do it in its own PR, in this order — the risk and the safety net are the same piece:- Merge the branch that renamed
S.min/S.max.pnpm fuzz:unionbuilds its baseline from a git ref, and every ref before that rename lacksS.gte/S.minLength, so the harness cannot build one today. - First commit of the follow-up: run
fuzz:union --ref=<merge-base>on an unchanged tree, to confirm the harness works against the new API. - Then make the change and diff, so the gate actually gates.
Three knock-ons to expect:
union.tsdecides a schema has refinements withschema.refiner !== U, which bounded schemas would stop setting;parse.ts's reverse swapsrefiner/inputRefiner, and a field carries no side; and bound checks would move to a fixed position relative topatternandrefine, changing which error surfaces when both fail. Messages survive only if the decoder-emitted check carries its own fail builder fromerrorMessage[key]— without that it reportsExpected int32where the refinement reports the bound.
- Merge the branch that renamed
-
A narrowing bound should retract the check it supersedes. Applying a bound that doesn't narrow is skipped outright, but in the other order the earlier check is already in the refiner chain and can't be pulled back, so
gte(1).gte(5)runsi>=1andi>=5where only the second matters. The advertised JSON Schema is right either way — but this is not codegen only, which is the part worth fixing first: a superseded check keeps its own message, so which one a caller sees depends on which check fires.S.string.with(S.maxLength, 5, "MAX").with(S.length, 3)advertisesstring.length == 3and reports "MAX" for a 6-character string and the generic message for a 4-character one, both being equally "too long" (specs/string-length-supersedes-maxLength-message.yaml). Compounding it,length()writes its message under bothminLengthandmaxLengthwhile the check it attaches reads onlyminLength— dead in that order, and in the reverse order (maxLength(5, "MAX").length(3, "EXACT")) it overwrites the caller's "MAX" so the survivingi.length<6check reports "EXACT". Retracting the check retires both. ArkType reduces both orders to a singlenumber >= 5node because its refinements intersect rather than append (min: (l, r) => l.isStricterThan(r) ? l : r), so it's reachable, but it needsinternalRefineto be able to replace a check rather than only push one. Of the rest: Zod narrows the field but runs both checks (same as here), Valibot keeps both in the pipe with no narrowing, and TypeBox lets the later option win outright — soType.Number({minimum:5})overridden by{minimum:1}accepts 3. -
Narrow a numeric format's range check against the schema's own bounds.
S.int32.with(S.gt, 5)emitsi<=2147483647&&i>=-2147483648&&i%1===0and theni>5, buti>5already implies the lower half;S.ltmakes the upper half dead the same way, andS.port(i>=0&&i<65536&&i%1===0) has the identical redundancy.numberDecoderhasinput.ein hand and the bounds are native fields on it, soint32FormatValidationcan drop whichever half the bound subsumes. Two costs: a value outside the format range but also outside the bound would report the bound's error rather thanExpected int32, andint32Checkwould stop being a module-level const — the one placeprimitives.tsdeliberately avoids a per-compile closure. -
A range
fromJSONSchemacan't represent resolves two different silent ways.integermaps onto int32, so a document whose bound falls outside that range has no faithful schema — and the two sides disagree about what to do.{minimum: 3000000000}collapses tonever(applyBoundreads the panic and gives up), rejecting the very values the document describes;{maximum: 3000000000}is dropped as non-narrowing, leaving a schema that rejects 2.5e9 and re-emits int32's edge as if the document had said it. Neither round-trips. The file already fails creation for keywords it cannot model rather than widening silently — an unrepresentable range wants the same answer, or a wider integer schema to land in. Pinned inspecs/jsonschema-int-{minimum,maximum}-above-int32.yaml. -
A bound is the only refinement that rewrites the schema's type expression. So it's the only one that shows up when the type check is what failed:
S.string.with(S.minLength, 2)reportsExpected string.length >= 2, received null, where the same string carryingS.patternorS.refinestill reportsExpected string, received null. The statement is true —nullis not a string of length >= 2 — but it points at a length nothing got far enough to have, and which refinement was applied shouldn't decide how a wrong-type failure reads. The two checks are already separate throws with separate builders (e[1]vse[0]), so a custom bound message correctly does not leak here; only the rendering does. Fixing it meansfailInvalidTyperendering the bare type where the bound check renders the bounded one — which costs theskipOverridepath a second caller. Pinned inspecs/string-minLength.yaml. -
Union headers enumerate bounds. The same rewrite reaches the union header, which is built from member expressions and deduped on rendered text. Bounded members no longer render alike, so three string members that used to collapse to
stringnow spellstring.length >= 5 | string | string.length <= 1, and a non-string input gets all three back as the answer to what was wrong with it. Visible inspecs/union3-same-tag-effect-boundary.yaml,union3-same-tag-validation-group,union2-refined-literal-fallbackandunion-large-planner. Three options, cheapest first: build the header frominputExpression(member, true)so it names the shapes and leaves the bounds to the per-member lines, which already carry them; or dedupe on the base rendering and re-add a bound only where it's what distinguishes two members; or keep the header and drop the, received Xeach sub-line repeats from it. The first restores every golden above to its pre-bounds text without losing detail, since the sub-lines are per-member already. -
A hard-coded array length should build a tuple.
S.array(S.string)withS.length(2)describes exactly[string, string], and withS.emptyexactly[]— but both inferstring[]and run a length check beside the array's own loop, where a tuple would carry the arity in its type and check it once.S.tuplealready exists and already emitsi.length===n, so this islength/emptyon an array tag rewriting to it rather than refining, and the win is a truer inferred type more than codegen. Two things to settle: the bound is reversible today and a tuple rewrite has to stay so, andlengthapplied to an already-bounded array (minLength(1).length(2)) has to pick one representation. Pinned inspecs/array-length.yamlandspecs/array-empty.yaml. -
A bound that doesn't narrow takes its custom message down with it.
gte(5).gte(1, "MY MESSAGE")drops the second bound — correctly, there is no failure left for it to guard — but the message is the caller's own text and it vanishes with no log, no error, and a schema that builds. A caller who writes a message and never sees it has no way to learn why. Either carry it onto the bound that survived, or reject a message supplied to a bound that doesn't narrow at construction, the way a contradictory pair already is. Same on the length side. Pinned inspecs/number-gte-redundant.yamlandspecs/string-length-redundant.yaml.
Known bugs left over from the validation refactor (val.validation: array<validationCheck>)
- Union discriminant hoists refinement checks with
&&instead of;. Now that refinements are structured checks, the union item merge loop hoists all checks on a val viaandJoinChecks, fusing type checks and refinement checks into one&&-joined condition with a single error throw. This causes two problems: (1)typeof==="string"&&length===Nshares one error instead of separate type/refinement errors, and (2) same-type items with different refinements (e.g.S.union([S.string->S.email, S.string->S.url])) lose per-item error messages. Fix: split hoisted checks byfailreference — first group (type checks) → discriminant condition, remaining groups (refinement checks) → body code ascond||fail;. For same-type items with different refinements, use if/else if dispatch on the refinement cond instead of try/catch. Failing regression tests inS_union_test.res. noValidationon a literal inside a union silently breaks dispatch.literalDecodershort-circuits whenexpectedSchema.noValidationis set and emits no check at all, so there's nothing for the union discriminant hoister to lift — that case becomes a catch-all. Fix: either emit the equality check regardless ofnoValidationwhen the val ends up inside a union, or rejectS.noValidationon a literal-in-union at schema construction time. Failing regression test:S_noValidation_test.res › Union dispatch still works when a case has noValidation.err.receivedis wrong for refine-chain vals on type failures. BecauseB.refinesets~schema=prev.expected,val.schemaon a refined val equals the target schema, andfailInvalidTypereadsval.schemaforreceived. Soerr.received === err.expectedon a primitive type failure. User-visible reason text is unaffected (it usesinput->stringify) but programmatic consumers readingerr.receivedget the target schema instead of the source type. Fix: either have the fail function reach throughval.prev.schema(with a comment on the invariant that validation-owning vals always have a prev) or stop mutatingval.schemato the target inrefineand walk the chain differently for "Expected X" messages. FIXME is tagged atSury.res:failInvalidType.
Pre-existing bugs surfaced by the TS-migration review (ported faithfully, fix separately)
exclusiveMaximumread asexclusiveMinimumin the max branches. BothtoJSONSchemaandfromJSONSchemamax handling readjsonSchema.exclusiveMinimumwhere they meanexclusiveMaximum(packages/sury/src/jsonschema.ts, the two max dispatch sites), so exclusive upper bounds round-trip incorrectly.S.mergeforces all keys of both objects intorequired.js_merge(packages/sury/src/jsapi.ts) rebuilds the merged object with every property required, dropping optionality that either side declared.inlinedValueFromStringescapes only"and\n. (packages/sury/src/types.ts) — other control characters (\r,\t, backslash itself) survive unescaped into generated code and error text.- ReDoS risk in
fromJSONSchemapatterns.new RegExp(jsonSchema.pattern)compiles untrusted patterns directly; a hostile JSON Schema can supply a catastrophic-backtracking pattern. - Async output refiners run on the Promise wrapper. Marked with a TODO in the source: an async transform followed by an output refiner can observe the pending Promise instead of the resolved value in some advanced-decoder paths.
- Empty async dict returns a forever-pending Promise.
S.recordwith an async item schema and{}input never resolves (Promise.allaggregation is skipped for zero keys). - Loop guard message says 100 but triggers at 50. The recursion guard in
packages/sury/src/parse.tsthrows "Loop count exceeded 100" behind a> 50check — align the number (and consider making the limit configurable). deepStrip/deepStrictdon't descend when a nested schema'sadditionalItemsalready matches the target mode.Object_setAdditionalItems(packages/sury/src/operations.ts) early-returns the schema unchanged whenevercurrentAdditionalItems === additionalItems, which also skips thedeeprecursion intoitems/properties— so a nested object whose own mode already matches the top-level target, but whose children don't, is left un-recursed-into. Present verbatim in the original ReScriptObject.setAdditionalItems(Sury.res), carried through the TS migration unchanged.- Homomorphic tuple-mapped types don't map variadic tuple elements.
UnknownArrayToOutput/UnknownArrayToInput(packages/sury/src/S.d.ts) guard onnumber extends T["length"]to distinguish tuples from plain arrays, but a variadic tuple like[string, ...number[]]also hasT["length"]widened tonumber, so it falls into the "return as-is" branch instead of mapping each element throughUnknownToOutput/UnknownToInput. Same guard existed in the original recursive_RestToOutput/_RestToInputaccumulator types, so this isn't a regression from the homomorphic-type rewrite — just an existing gap now easier to spot in the simpler form.
v11 initial
- Add
s.parseChildto EffectContext ??? - Support arrays for
S.to - Remove fieldOr in favor of optionOr?
- Allow to pass custom error message via
.with - Make S.to extensible
Add S.Date (S.instanceof) and remove S.datetime(S.date added; S.datetime kept for backward compat)- Add refinement info to the tagged type
v???
S.promise: S.t<'value> => S.t<promise<'value>>andS.await: S.t<promise<'value>> => S.t<'value>- Remove
S.deepStrictandS.deepStripin favor ofS.deep(if it works) - Make S.serializeToJsonString super fast
- Somehow determine whether transformed or not (including shape)
- Add JSDoc
- s.optional for object
- S.transform(s => { s.reverse(input => input) // Or s.asyncReverse(input => Promise.resolve(input)) input => input }) // or asyncTransform // Maybe format ?
- Clean up Caml_option.some, Js_dict.get
- Github Action: Add linter checking that the generated files are up to date (?)
- Support optional fields (can have problems with serializing) (???)
- S.mutateWith/S.produceWith (aka immer) (???)
- Add S.function (?) (An alternative for external ???)
let trimContract: S.contract<string => string> = S.contract(s => {
s.fn(s.arg(0, S.string))
}, ~return=S.string)
- Use internal transform for trim
- Add schema input to the error ??? What about build errors?
- async serializing support
- Add S.promise
- S.create / S.validate
- Add S.codegen
- Rename S.inline to S.toRescriptCode + Codegen type + Codegen schema using type
- Make
error.reasontree-shakeable - S.toJSON/S.castToJson ???
- S.produce
- S.mutator
- Check only number of fields for strict object schema when fields are not optional (bad idea since it's not possible to create a good error message, so we still need to have the loop)
Articles
- Write an article about creating an AI-friendly JS library (how the API design, type overloads like
S.is/S.assertaccepting both arg orders, and error messages make Sury easy for both humans and LLMs to use)