Sury Documentation

repository·main·Indexed 19 days ago

https://github.com/dzakh/sury

A high-performance schema library for JavaScript, TypeScript, and ReScript. Sury allows developers to define data shapes for parsing, validation, transformation, serialization, and JSON Schema generation. It features compiled schemas for extreme execution speed, a modular API for effective tree-shaking, and support for the Standard Schema specification. The library includes sury-ppx for automatic schema generation from ReScript types and tools for JSON-Schema-Test-Suite compliance checking.

Tokens
42.2K
Snippets
154
Records
183
Agent score
66%

What's inside Sury

  1. Understand Sury's architecture and performance characteristics

    main

    Sury is a high-performance validation library designed for small bundle sizes and extreme execution speed.

    Key Architectural Traits

    • Modular API: Instead of large classes, Sury uses small, independent functions. This allows bundlers to perform effective tree-shaking, potentially reducing shipped size by up to 2× compared to Zod.
    • Compiled Schemas: For maximum performance, Sury compiles schemas into specialized code using new Function. This makes it one of the fastest composable validation libraries available.
    • Standard Schema Support: Sury implements the Standard Schema specification, making it compatible with over 32+ libraries that support this spec.
    • JSON Schema Integration: You can convert schemas to and from JSON Schema using S.toJSONSchema and S.fromJSONSchema.

    Performance Comparison (Summary)

    Sury is optimized for both bundle size and runtime throughput, specifically excelling in 'Parse with the same schema' benchmarks due to its compilation approach.

  2. Understand the Sury Codec Specification

    main

    The codec specification defines how conversions (using S.to or implicit reversed schemas) determine how values are decoded from one type to another. The logic is governed by four primary rules based on whether the source and target are unions or non-unions.

    Core Concepts

    • Type Matching: Two schemas have the same type if their type tags match (e.g., class for instances, format for primitives). S.int32 and S.number are considered different types.
    • Strict Equality for Un-tagged Schemas: Recursive schemas or unions treated as normal schemas only match if they are the exact same schema reference.
    • Flattening: Nested unions are flattened before rules are applied. For example, S.union([S.string, S.union([S.number, S.boolean])]) is treated as a three-variant union.
    • S.never: Marks unreachable paths. S.never variants are ignored by type matching and do not count toward coverage requirements.
  3. Serialize with transformations using S.reverse

    main

    When using transformations like S.to (coercion) or S.shape (restructuring), S.encoder will reverse these transformations to return data in the original input format.

    If you need to perform a validating reverse pass (validating the output against the input schema), use S.reverse(schema). This returns a new schema where the Input and Output types are swapped.

    const userSchema = S.schema({
      USER_ID: S.string.with(S.to, S.bigint),
      USER_NAME: S.string,
    }).with(S.shape, (input) => ({ id: input.USER_ID, name: input.USER_NAME }));
    
    // Validating reverse pass
    S.parser(S.reverse(userSchema))({ id: 0n, name: "Dmitry" });
    // => { USER_ID: "0", USER_NAME: "Dmitry" }
  4. Handle numeric bounds and refinements

    main

    Sury uses bounds (like S.gt, S.lt, S.gte, S.lte) to constrain numeric values.

    Key behaviors to note:

    • Redundant Bounds: Applying a narrowing bound (e.g., gte(5)) after a wider bound (e.g., gte(1)) may result in the earlier check being superseded.
    • Custom Messages: If a bound is superseded by a more restrictive one, the custom error message provided to the superseded bound may be lost.
    • Type Expressions: Bounds are the only refinements that rewrite the schema's type expression (e.g., S.string.with(S.minLength, 2) renders as string.length >= 2).
  5. Requirements for decodable variants in conversions

    main

    When using S.to to define a custom decoder (conversion), every variant in the source must be decodable into the target type. If any single variant in a union cannot be decoded into the target, the entire operation is rejected at creation time with an error like Can't decode [source] to [target]. Use S.to to define a custom decoder.

    Key Rules:

    • A variant is never silently dropped from the generated code.
    • A conversion with no decodable variant will fail to compile.
    • S.never can be used to mark a path as deliberately unreachable, which prevents it from triggering rejection during the decoder build process.
    // ❌ This will be rejected because boolean -> symbol has no decoder
    S.boolean.with(S.to, S.union([S.string, S.symbol]));
    
    // ✅ This is valid because the symbol path is explicitly unreachable
    S.boolean.with(S.to, S.union([S.string, S.never.with(S.to, S.symbol)]));
  6. Use pipelines with S.to for multi-stage transformations

    main

    Sury allows you to build multi-stage data pipelines using S.to. Conversion targets are ordinary schemas (like S.json, S.jsonString, S.unknown, S.date, or S.uint8Array) that can be chained together. Sury compiles the entire pipeline into a single optimized function using new Function to avoid runtime overhead.

    This works both at the top level and inside nested schemas (e.g., within s.field or S.array).

    // Top-level pipeline: Parse a JSON string, then validate against userSchema
    rawString->S.decodeOrThrow(~from=S.jsonString, ~to=userSchema)
    
    // Nested pipeline: A field that arrives as bytes, decodes to UTF-8 string, then to a Date
    let apiUserSchema = S.schema(s => {
      "createdAt": s.field("createdAt", S.uint8Array->S.to(S.string)->S.to(S.date))
    })
  7. How union variant failures and fall-through work

    main

    In Sury, when a variant within a union fails validation, the value is automatically passed to the next variant in the list. A failure is triggered by:

    • A discriminant mismatch.
    • A refinement failure.
    • An S.Error raised within the variant's body.

    If all variants fail, the union throws an error that aggregates the specific reasons from each variant.

    Important: Exception Propagation If a variant throws an exception that is not a Sury error (e.g., a TypeError caused by a bug in a predicate function), that exception propagates immediately and is not treated as a validation failure. It will not fall through to the next variant. This prevents bugs from being silently swallowed by catch-all variants.

    // Example of how a TypeError propagates instead of falling through
    S.union([
      S.string.with(S.refine, (v) => v.trim().length > 0), 
      S.string
    ]);
    // If the predicate logic itself crashes with a TypeError, 
    // it surfaces immediately rather than falling through to S.string.
  8. Use Goldens to track schema coverage

    main

    Goldens are JSON files located in goldens/<dialect>.json that record a summary and the sorted IDs of every failing or errored test case.

    • Diffing: Because only failures are listed, a git diff shows exactly what changed: removed lines are newly passing tests, and added lines are regressions.
    • Strictness: The pnpm compliance command fails if there is any drift (improvement or regression) in the goldens. Improvements must be committed by updating the golden in the same PR.
    • Optional Tests: The optional/ directory (covering formats, bignum, and content encoding) is exploratory and is not snapshotted/goldened.
  9. Understand how JSON-Schema-Test-Suite compliance is measured

    main

    Compliance is measured by running each suite assertion as S.fromJSONSchema(schema) followed by S.parser(schema)(data). A test passes if the parse outcome matches the suite's expected valid state.

    Scoring and Errors

    • Errored: A schema that throws during conversion or compilation is marked as errored.
    • Divergence: A discrepancy between S.is (the semantic operation) and S.parser (the parsing operation) is considered a Sury bug. These are tracked in goldens and can be inspected via report --divergent.

    Understanding the Score

    The score measures faithfulness to JSON Schema semantics. Common reasons for non-100% scores include:

    • Under-validation: Keywords not yet implemented by fromJSONSchema are silently ignored, allowing invalid data to pass.
    • Over-strictness: A design choice where fromJSONSchema builds typed schemas that reject non-applicable types (e.g., a schema with maxLength rejecting a number), whereas JSON Schema keywords are technically type-conditional assertions.
  10. Array to Tuple conversion with S.length()

    main
    When applying a hard-coded length to an array using S.length(n) or S.empty, Sury can treat the resulting schema as a tuple rather than a plain array. This provides a more accurate inferred type (e.g., [string, string] instead of string[]) and more efficient validation.
  11. Use S.to() for type transformations

    main
    The S.to() function is used to transform a value from one type to another. It is a core part of the transformation pipeline, allowing you to define how a value is converted (e.g., from a string to a number).
  12. Handle Sury validation errors in ReScript

    main

    Sury throws S.error exceptions containing detailed information about validation failures. To handle these errors, use a try/catch block and catch the S.Exn(error) pattern to access the error details, such as error.message.

    try true->S.parseOrThrow(~to=schema) catch {
    | S.Exn(error) => Console.log(error.message)
    }