Skip to content

fix(deps): update dependency sury to v11.0.0-rc.2 - #2345

Merged
renovate[bot] merged 1 commit into
masterfrom
renovate/sury-11.x
Sep 5, 2026
Merged

fix(deps): update dependency sury to v11.0.0-rc.2#2345
renovate[bot] merged 1 commit into
masterfrom
renovate/sury-11.x

Conversation

@renovate

@renovate renovate Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
sury 11.0.0-alpha.1011.0.0-rc.2 age confidence

Release Notes

DZakh/sury (sury)

v11.0.0-rc.2

Compare Source

What's Changed

Polished Custom Codecs

When no built-in conversion fits, pass your own decode and encode:

- const schema = S.string.with(
-   S.to,
-   S.number,
-   (string) => parseInt(string, 10),
-   (number) => number.toString(),
- });
+ const schema = S.string.with(S.to, S.number, {
+   decode: (string) => parseInt(string, 10),
+   encode: (number) => number.toString(),
+ });

S.parser(schema)("123"); //? 123
- S.parser(schema)("abc"); //? NaN (invalid)
+ S.parser(schema)("abc"); //? throws: Expected number, received NaN
S.encoder(schema)(123); //? "123"

Unlike the previous versions, the result of decode is validated by the target schema, so a coder that
returns the wrong thing fails right there instead of leaking a bad value.

Besides a function, each direction accepts:

// "auto": keep the built-in conversion for that direction
S.string.with(S.to, S.string, { decode: (s) => s.trim(), encode: "auto" });

// "never": this direction is impossible, fail when an operation needs it
S.string.with(S.to, S.number, { decode: (s) => s.length, encode: "never" });

// {async: fn}: run with S.asyncParser / S.asyncEncoder
const user = S.schema({ id: S.uuid, name: S.string });

S.uuid.with(S.to, user, {
  decode: { async: (id) => loadUser(id) },
  encode: (user) => user.id,
});

Describe what you decode into. The target is what validates the coder's result,
types the output, and exports to JSON Schema:

const csv = S.string.with(S.to, S.array(S.string), {
  decode: (csv) => csv.split(","),
  encode: (items) => items.join(","),
});

S.parser(csv)("a,b,c"); //? ["a", "b", "c"]
S.encoder(csv)(["a", "b"]); //? "a,b"

🧠 S.any accepts anything, so it's the escape hatch for a value no schema
can describe. It checks nothing about what the coder returns — reach for it
last, not first.
Passing a single function is a decode-only shorthand. Encoding such a schema
fails, since Sury has no way back:

const schema = S.string.with(S.to, S.number, (string) => string.length);

S.parser(schema)("abc"); //? 3
S.encoder(schema); //? throws: Encoding is ambiguous when only a decode function is provided

🧠 Prefer the built-in S.string.with(S.to, S.number) when it does the job.

ReScript

ReScript conversions also changed in the same direction, but with a few differences.

  1. New S.any schema

  2. S.to now accepts an optional ~custom codecs object

    // Trim on decode, built-in validation on encode
    S.string->S.to(S.string, ~custom={decode: Sync(String.trim), encode: Auto})
    
    // Load a user by id
    S.uuid->S.to(
      userSchema,
      ~custom={decode: Async(userId => loadUser(~userId)), encode: Sync(user => user.id)},
    )

    Each direction is one of:

Sync(fn)   // a coder
Async(fn)  // a coder returning a promise, run with parseAsyncOrThrow
Auto       // keep the built-in conversion for this direction
Never      // this direction is impossible, fail when an operation needs it

Two differences from the TS version: decoding/encoding is between fromSchema.output and toSchema.output, and currently there's no validation that the transformed value matches the correct type.

  1. The S.transform is removed in favor of S.to with an explicit target. Use S.any to match previous behavior:
    - fromSchema->S.transform(() => {parser: fn, serializer: fn})
    + fromSchema->S.to(S.any, ~custom={decoder: Sync(fn), encoder: Sync(fn)})
    
    - fromSchema->S.transform(() => {asyncParser: fn})
    + fromSchema->S.to(S.any, ~custom={decoder: Async(fn), encoder: Never})
Other Changes

Full Changelog: DZakh/sury@v11.0.0-rc.1...v11.0.0-rc.2

v11.0.0-rc.1

Compare Source

Faster, Safer, Broader

  • 🚀 Encode faster than JSON.stringify — no intermediate object, no corrupted output.
  • 📄 From JSON Schema with type inference — recursion included, create schema and get the type for free.
  • 🔤 New schemas and refinementsS.integer, S.blob, S.file, S.gte, S.maxLength, S.maxSize, S.multipleOf, 14 new string formats and more.
  • 🪆 Inline container schema definitionsS.array(S.schema({ id: S.string })) -> S.array({ id: S.string }).
  • 🧹 schema.toString()console.log("This is " + S.string) // This is Schema<string>
  • ~standard.validate — 158 ns → 19 ns.

Encode faster than JSON.stringify 🚀

const schema = S.schema({ id: S.bigint, name: S.string });

S.encoder(schema, S.jsonString)({ id: 42n, name: "Dmitry" });
// => '{"id":"42","name":"Dmitry"}'

It compiles to the text itself, not to a value you stringify afterward:

(i) => '{"id":"' + i["id"] + '","name":' + JSON.stringify(i["name"]) + "}";

Types JSON.stringify refuses are ordinary fields here. Values it silently corrupts throw instead:

const schema = S.schema({
  id: S.bigint,
  at: S.date,
  price: S.number,
});
const encode = S.encoder(schema, S.jsonString);

encode({
  id: 9007199254740993n,
  at: new Date("2026-01-15T10:30:00.000Z"),
  price: 9.99,
});
// => '{"id":"9007199254740993","at":"2026-01-15T10:30:00.000Z","price":9.99}'

encode({ id: 1n, at: new Date(), price: Infinity });
// => throws S.Error: Failed at ["price"]: Expected JSON, received Infinity

JSON.stringify({ price: Infinity });
// => '{"price":null}'
Encode to JSON string Sury JSON.stringify fast-json-stringify
API response (user profile, 7 fields) 250 ns 396 ns 301 ns
Event feed (50 tagged-union events) 3.67 µs 4.61 µs 13.52 µs
bigint id + binary payload + Date 1.02 µs 1.10 µs 1.11 µs

Faster than JSON.stringify, and 3.5× lighter than fast-json-stringify — 16.2 kB against 56.7 kB, encoder included.

From JSON Schema with type inference 📄

Define your schemas in plain JSON Schema with type inference - no Sury-specific API needed.

const commentSchema = S.fromJSONSchema({
  $ref: "#/$defs/comment",
  $defs: {
    comment: {
      type: "object",
      properties: {
        text: { type: "string" },
        replies: { type: "array", items: { $ref: "#/$defs/comment" } },
      },
      required: ["text"],
    },
  },
});
//? S.Schema<{ text: string; replies?: ...[] | undefined }>

Get all powers of Sury - type inference, validation, encoding, decoding, Standard Schema, and more:

S.parser(commentSchema);
S.is(commentSchema, {...}); // Easy and safe type-guard
S.decoder(S.jsonString, commentSchema); // No need for JSON.parse in your code
S.encoder(commentSchema, S.jsonString); // Faster than JSON.stringify
commentSchema["~standard"].validate({...}); // Ultra-fast validation supported by 40+ libraries
commentSchema["~standard"].jsonSchema.input({ target: "openapi-3.0" }); // Regenerate JSON Schema for OpenAPI 3.0

New schemas and refinements 🔤

Control divisibility with .with(S.multipleOf, value) which also powers a new built-in S.integer schema:

S.integer;
S.number.with(S.multipleOf, 1);
//? S.Schema<number>

Full JSON Schema string format vocabulary:

S.isoDate;
S.isoTime;
S.isoDateTime;
S.duration;
S.ipv4;
S.ipv6;
S.jsonPointer;
S.relativeJsonPointer;
S.uri;
S.uriReference;
S.uriTemplate;
S.iri;
S.iriReference;
S.email;
S.idnEmail;
S.hostname;
S.idnHostname;

// Use as standalone schema - not refinement
S.is(S.ipv4, "1.2.3.4"); // true

S.url becomes an instance of the JS URL class the way S.date is an instance of Date. Bare it accepts a URL; S.string.with(S.to, S.url) parses a string into one and encodes back as uri string.

Working with Files or Blobs - no problem:

S.blob;
S.file;

Built-in base64/uint8Array/utf8 decoders and encoders coming in next release.

S.min and S.max meant "length" or "bound," depending on what you pass them. They're split:

S.number.with(S.gte, 10); // was S.min
S.number.with(S.gt, 1).with(S.lt, 3); // new
S.string.with(S.maxLength, 3); // was S.max
S.file.with(S.minSize, 16).with(S.maxSize, 1024); // new
S.blob.with(S.size, 256); // new

A literal length pins the type:

S.array(S.string).with(S.length, 2);
//? S.Schema<[string, string]>

An expression in error messages got cleaner and more concise:

const evenNumberAbsoluteValueLessThan50 = S.number
  .with(S.multipleOf, 2)
  .with(S.gt, -50)
  .with(S.lt, 50);

S.assert(evenNumberAbsoluteValueLessThan50, 9);
// Expected -50 < (number % 2) < 50, received 9

// You can also get it separately with S.inputExpression or S.outputExpression (previously only S.toExpression was available)
S.inputExpression(evenNumberAbsoluteValueLessThan50);
// "-50 < (number % 2) < 50"

Inline container schema definitions 🪆

Anywhere a schema is accepted, a raw definition works too. Previously, explicit S.schema was required:

S.array({ id: S.string }); // { id: string }[]
S.record({ n: S.number }); // { [k: string]: { n: number } }
S.optional([S.string, "ok"]); // [string, "ok"] | undefined
S.nullable("foo"); // "foo" | null

Works for S.array, S.record, S.optional, S.nullable, S.nullish and S.compactColumns.

schema.toString() 🧹

We added schema.toString() which returns syntactic representation like Schema<TInput, TOutput>:

console.log(`${schema} with transformation is not allowed.`); // "Schema<string, int32> with transformation is not allowed."

Faster around the compiled code ⚡

The compiled operation was never the bottleneck. The call logic around it was:

schema["~standard"].validate    158 ns -> 19 ns
S.is / S.assert                 165 ns -> 54 ns
S.parser(schema) lookup         106 ns -> 21 ns

Sury has lazy compilation and caching logic, so we don't compile operations you don't need, and every S.parser(schema) call, e.g., in a React component, doesn't compile a new function.

In the release, the logic was improved, which resulted in a noticeable performance increase.

ReScript and the PPX 🐫

Recursive types support
@schema
type rec node = {
  id: string,
  children: array<node>,
}

// Generated by PPX ⬇️
let nodeSchema = S.recursive("node", nodeSchema =>
  S.schema(s => {
    id: s.matches(S.string),
    children: s.matches(S.array(nodeSchema)),
  })
)

Mutually recursive types work too:

@schema
type rec expr = Num(int) | Block(array<stmt>)
@schema
and stmt = {label: string, body: expr}
@s.with(S.t<'value> => S.t<'value>)

Applies to: type declarations, type expressions

Transforms the generated schema with the provided function:

@schema
type t = @s.with(S.trim) string

// Generated by PPX ⬇️
let schema = S.string->S.trim

Use _ to pass extra arguments, and repeat the attribute to chain transforms — they apply in order:

@schema
type t = @s.with(S.trim) @s.with(S.minLength(_, 5)) string

// Generated by PPX ⬇️
let schema = S.string->S.trim->S.minLength(5)

The transform must return a schema of the same type — changing it (e.g. with S.to) is a compile-time error.

Also sury-ppx ships an Apple Silicon binary.

Let's connect 🚀

Contact me on GitHub or X if you have any questions 😁 And enjoy using Sury!

v11.0.0-rc.0

Compare Source

Welcome RC.0 🎉

Breaking Changes 💥

  • Renamed union->anyOf tag in internal schema representation.
  • Changed union conversion logic (see below)

New Union Conversion Logic

S.to now has defined behaviour with a union on either side of the conversion. There are three cases, and anything that doesn't fall cleanly into one of them is rejected where it's written rather than guessed at.

Single type → union

Members are tried in the order you wrote them; the first one that accepts the value wins.

const schema = S.json.with(S.to, S.union([S.bigint, S.string]));

S.parser(schema)("123"); // 123n — the bigint member comes first
S.parser(schema)("abc"); // "abc" — not a valid bigint, so the string member takes it
S.parser(schema)(true); // throws — no member accepts a boolean

The rule that makes this predictable: a value is only converted into a member type the source can't produce itself. JSON has no bigints, so strings are offered to S.bigint — but JSON already has strings, so the S.string member only accepts actual strings. That's why true isn't converted to "true" above, even though boolean → string is a supported conversion on its own.

Union → single type

The mirror image — each member converts to the target the same way it would with a direct S.to.

const schema = S.union([S.bigint, S.boolean]).with(S.to, S.string);

S.parser(schema)(123n); // "123"
S.parser(schema)(true); // "true"
Union → union

Values pass through to the member of the same type on the other side. Nothing is converted, so every member needs a counterpart — with one exception: an undefined member without a counterpart may pair with a null member on the other side, and vice versa.

S.union([S.string, S.number]).with(S.to, S.union([S.number, S.string])); // ✅ both pass through
S.optional(S.string).with(S.to, S.nullable(S.string)); // ✅ undefined <-> null
S.optional(S.string).with(S.to, S.nullable(S.boolean)); // ❌ string has no counterpart
Matching rules
  • Formats count as distinct types: S.int32 won't match a plain S.number member, and S.json won't match S.string.
  • Nested unions are treated as one flat union: S.union([S.string, S.union([S.number, S.boolean])]) has three members.
  • When a value fails a member — wrong type, failed refinement, or an error thrown inside it — the next member gets a try. Only when all members fail does the union throw, listing each member's reason.
When a conversion is rejected

Some conversions have more than one reasonable meaning, and some have none. Rather than guess, Sury rejects those with an Invalid operation error at the S.parser / S.encoder call — not later, on each value — and the error names a rewrite that says what you mean.

Ambiguous. Given "123" — should it stay a string, or become a number? Both readings are sensible, so you pick:

S.string.with(S.to, S.union([S.number, S.string]));
// Invalid operation: can't convert string to number | string — string has the same
// type as the source and the others don't.

// Convert to a number when possible, keep the string otherwise:
const asNumber = S.string.with(S.to, S.union([S.string.with(S.to, S.number), S.string]));
S.parser(asNumber)("123"); // 123
S.parser(asNumber)("abc"); // "abc"

// Or pass strings through, never producing a number:
const asString = S.string.with(S.to, S.union([S.never.with(S.to, S.number), S.string]));
S.parser(asString)("123"); // "123"
S.parser(asString)("abc"); // "abc"

The two unions don't cover each other. Union-to-union converts nothing, so a member with no same-type counterpart has nowhere to go:

S.union([S.string, S.number]).with(S.to, S.union([S.number, S.string, S.boolean]));
// Invalid operation: … boolean has no same-type variant on the other side.
S.optional(S.string).with(S.to, S.nullable(S.boolean)); // ❌ string doesn't match boolean
S.optional(S.string).with(S.to, S.nullable(S.string.with(S.to, S.boolean))); // ✅

No conversion exists. If a conversion between two types isn't supported outside a union, putting it inside one doesn't change that. S.never marks a member unreachable:

S.boolean.with(S.to, S.union([S.string, S.symbol])); // ❌ boolean -> symbol isn't supported
S.union([S.boolean, S.symbol]).with(S.to, S.string); // ❌ symbol -> string isn't supported
S.boolean.with(S.to, S.union([S.string, S.never.with(S.to, S.symbol)])); // ✅ symbol marked unreachable

Union conversion always validates every member, so transformed unions stay consistent across decode and encode.

Where this is written down
  • docs/js-usage.mdConverting to / from a union, and the ReScript mirror in docs/rescript-usage.md. Every sample above is verified against the built library.
  • packages/sury/specs/*.yaml — the behavior above as machine-derived goldens, one per shape.

By @​DZakh in #​317

ReScript PPX

Internal

What's Next?

I plan to polish the new union conversion logic, improve error messages, tree-shaking, JSON Schema compatibility, and add a few more built-in schemas. Ready for a final release after this.

Full Changelog: DZakh/sury@v11.0.0-alpha.11...v11.0.0-rc.0

v11.0.0-alpha.11

Compare Source

What's Changed

New Contributors

Full Changelog: DZakh/sury@v11.0.0-alpha.10...v11.0.0-alpha.11


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Enabled.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot added the renovatebot label Sep 4, 2026
@renovate
renovate Bot force-pushed the renovate/sury-11.x branch 4 times, most recently from e0d2671 to 4721ecb Compare September 4, 2026 21:23
@renovate
renovate Bot force-pushed the renovate/sury-11.x branch from 4721ecb to cc9ec99 Compare September 5, 2026 02:14
@renovate
renovate Bot merged commit 470e19b into master Sep 5, 2026
7 checks passed
@renovate
renovate Bot deleted the renovate/sury-11.x branch September 5, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants