Skip to content

Releases: DZakh/rescript-schema

v6.4.0

07 Feb 12:49
Compare
Choose a tag to compare
  • PPX: Add support for built-in dict type
  • PPX: Fix @as inside inline records in variants
  • Change the rescript peer dependency version to 11.x

Full Changelog: v6.3.0...v6.4.0

v6.3.0

08 Dec 22:23
Compare
Choose a tag to compare

🚀 API additions

  • Added space option to the S.jsonString utility schema. It allows to control the JSON format on serializing.
  • Added S.nullable schema (or S.nullish in JS/TS API). Nullable/Nullish schemas will accept both undefined and null

💥 Bug fixes

  • Fix union operations failing with some item schemas being invalid even before trying to run an operation with other item schemas (which are valid). Note that the bug was introduced in V5.

⚙️ New PPX attributes

@s.matches(S.t<'value>)

⚠️ It was previously @schema(S.t<'value>), which is now deprecated and will be completely removed in v7.

Applies to: type expressions

Specifies custom schema for the type.

@schema
type t = @s.matches(S.string->S.String.url) string

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

@s.null

Applies to: option type expressions

Tells to use S.null for the option schema constructor.

@schema
type t = @s.null option<string>

// Generated by PPX ⬇️
let schema = S.null(S.string)

@s.nullable

Applies to: option type expressions

Tells to use S.nullable for the option schema constructor.

@schema
type t = @s.nullable option<string>

// Generated by PPX ⬇️
let schema = S.nullable(S.string)

@s.default('value)

Applies to: type expressions

Wraps the type expression schema into an option with the provided default value.

@schema
type t = @s.default("Unknown") string

// Generated by PPX ⬇️
let schema = S.option(S.string)->S.Option.getOr("Unknown")

It might be used together with @s.null or @s.nullable:

@schema
type t = @s.nullable @s.default("Unknown") string

// Generated by PPX ⬇️
let schema = S.nullable(S.string)->S.Option.getOr("Unknown")

@s.defaultWith(unit => 'value)

Applies to: type expressions

Wraps the type expression schema into an option with callback to get the default value.

@schema
type t = @s.defaultWith(() => []) array<string>

// Generated by PPX ⬇️
let schema = S.option(S.array(S.string))->S.Option.getOrWith(() => [])

🧠 The same as @s.default it might be used together with @s.null or @s.nullable

@s.describe(string)

Applies to: type expressions

Adds description property to the generated schema.

@schema
type t = @s.describe("A useful bit of text, if you know what to do with it.") string

// Generated by PPX ⬇️
let schema = S.string->S.describe("A useful bit of text, if you know what to do with it.")

This can be useful for documenting a field, for example in a JSON Schema using a library like rescript-json-schema.

@s.deprecate(string)

Applies to: type expressions

Adds deprecation property to the generated schema.

@schema
type t = @s.deprecate("Will be removed in APIv2") string

// Generated by PPX ⬇️
let schema = S.string->S.deprecate("Will be removed in APIv2")

This can be useful for documenting a field, for example in a JSON Schema using a library like rescript-json-schema.

Full Changelog: v6.2.0...v6.3.0

v6.2.0

03 Dec 18:53
Compare
Choose a tag to compare

ReScript Schema PPX 🎉

ReScript PPX to generate rescript-schema from type.

🧠 It's 100% opt-in. You can use rescript-schema without ppx.

Install

npm install rescript-schema rescript-schema-ppx

Then update your rescript.json config:

{
  ...
+ "bs-dependencies": ["rescript-schema"],
+ "bsc-flags": ["-open RescriptSchema"],
+ "ppx-flags": ["rescript-schema-ppx/bin"],
}

Basic usage

// 1. Define a type and add @schema attribute
@schema
type rating =
  | @as("G") GeneralAudiences
  | @as("PG") ParentalGuidanceSuggested
  | @as("PG13") ParentalStronglyCautioned
  | @as("R") Restricted
@schema
type film = {
  @as("Id")
  id: float,
  @as("Title")
  title: string,
  @as("Tags")
  tags: @schema(S.option(S.array(S.string))->S.Option.getOr([])) array<string>,
  @as("Rating")
  rating: rating,
  @as("Age")
  deprecatedAgeRestriction: @schema(S.option(S.int)->S.deprecate("Use rating instead")) option<int>,
}

// 2. ppx will generate the code below
let ratingSchema = S.union([
  S.literal(GeneralAudiences),
  S.literal(ParentalGuidanceSuggested),
  S.literal(ParentalStronglyCautioned),
  S.literal(Restricted),
])
let filmSchema = S.object(s => {
  id: s.field("Id", S.float),
  title: s.field("Title", S.string),
  tags: s.fieldOr("Tags", S.array(S.string), []),
  rating: s.field("Rating", ratingSchema),
  deprecatedAgeRestriction: s.field("Age", S.option(S.int)->S.deprecate("Use rating instead")),
})

// 3. Parse data using the schema
// The data is validated and transformed to a convenient format
%raw(`{
  "Id": 1,
  "Title": "My first film",
  "Rating": "R",
  "Age": 17
}`)->S.parseWith(filmSchema)
// Ok({
//   id: 1.,
//   title: "My first film",
//   tags: [],
//   rating: Restricted,
//   deprecatedAgeRestriction: Some(17),
// })

// 4. Transform data back using the same schema
{
  id: 2.,
  tags: ["Loved"],
  title: "Sad & sed",
  rating: ParentalStronglyCautioned,
  deprecatedAgeRestriction: None,
}->S.serializeWith(filmSchema)
// Ok(%raw(`{
//   "Id": 2,
//   "Title": "Sad & sed",
//   "Rating": "PG13",
//   "Tags": ["Loved"],
//   "Age": undefined,
// }`))

// 5. Use schema as a building block for other tools
// For example, create a JSON-schema with rescript-json-schema and use it for OpenAPI generation
let filmJSONSchema = JSONSchema.make(filmSchema)

🧠 Read more about schema usage in the ReScript Schema for ReScript users documentation.

API reference

@schema

Applies to: type declarations, type signatures

Indicates that a schema should be generated for the given type.

@schema(S.t<'value>)

Applies to: type expressions

Specifies custom schema for the type.

Acknowledgments

Thanks to the creator of ppx-spice @moondaddi 🙏. I took a lot of inspiration from the library's source code. It was especially helpful, since the ppx was my first OCaml program I've been working on.

Full Changelog: v6.1.0...v6.2.0

v6.1.0

28 Nov 09:29
Compare
Choose a tag to compare

New S.schema helper

(schemaCtx => 'value) => S.t<'value>

It's a helper built on S.literal, S.object, and S.tuple to create schemas for runtime representation of ReScript types conveniently.

@unboxed
type answer =
  | Text(string)
  | MultiSelect(array<string>)
  | Other({value: string, @as("description") maybeDescription: option<string>})

let textSchema = S.schema(s => Text(s.matches(S.string)))
// It'll create the following schema:
// S.string->S.variant(string => Text(string))

let multySelectSchema = S.schema(s => MultiSelect(s.matches(S.array(S.string))))
// The same as:
// S.array(S.string)->S.variant(array => MultiSelect(array))

let otherSchema = S.schema(s => Other({
  value: s.matches(S.string),
  maybeDescription: s.matches(S.option(S.string)),
}))
// Creates the schema under the hood:
// S.object(s => Other({
//   value: s.field("value", S.string),
//   maybeDescription: s.field("description", S.option(S.string)),
// }))
//       Notice how the field name /|\ is taken from the type's @as attribute

let tupleExampleSchema = S.schema(s => (#id, s.matches(S.string)))
// The same as:
// S.tuple(s => (s.item(0, S.literal(#id)), s.item(1, S.string)))

Also, it works fantastic with discriminated unions:

@tag("kind")
type shape =
  | @as("circle") Circle({radius: float})
  | @as("square") Square({x: float})
  | @as("triangle") Triangle({x: float, y: float})

// With S.schema
let circleSchema = S.schema(s => Circle({
  radius: s.matches(S.float),
}))

// With S.object
let circleSchema = S.object(s => {
  s.tag("kind", "circle")
  Circle({
    radius: s.field("radius", S.float),
  })
})

🧠 Note that S.schema relies on the runtime representation of your type, while S.object/S.tuple are more flexible and require you to describe the schema explicitly.

Full Changelog: v6.0.0...v6.1.0

v6.0.0

23 Nov 20:53
Compare
Choose a tag to compare

The package is renamed to rescript-schema (previously rescript-struct)

Migration guide:

  • Uninstall rescript-struct and install rescript-schema instead
  • Rename RescriptStruct->RescriptSchema and rescript-struct->rescript-schema in your rescript.json/bsconfig.json file
  • For JS/TS api users, rename the S.Struct type to S.Schema
  • Change struct to schema in your code if you followed the suggested naming from the documentation

Full Changelog: v5.1.1...v6.0.0

v5.1.1

08 Nov 09:20
Compare
Choose a tag to compare
  • Publish S.bs.mjs and S.bs.js files to fix usage without a compiler
  • Build using rescript@11-rc.5
  • Add eslint-disable comment on top of the RescriptStruct.gen.ts

v5.1.0

12 Oct 11:25
Compare
Choose a tag to compare

JS/TS as a full-fledged target of rescript-struct

Starting from the version JS/TS API users are as crucial as ReScript ones. rescript-struct has a unique design combining good DX, a small JS footprint, and insane performance. I see how it can benefit not only the ReScript ecosystem but also become a good fit for JS/TS performance-critical projects.

To support the vision, I've split the documentation into 4 parts:

Now, each target audience will be able to find as detailed answers as possible without looking at the source code. Also, I've added the Table of contents section to improve your reading experience.

Comparison section

When talking about statistics, I prefer numbers over gut feeling. So, I've prepared a comparison section where I compare rescript-struct with the currently hyping Zod and Valibot libraries. I've tried to highlight the strong sides of each library and keep it as honest as possible. Here's the gist of the comparison:

rescript-struct@5.1.0 Zod@3.22.2 Valibot@0.18.0
Total size (minified + gzipped) 9.67 kB 13.4 kB 6.73 kB
JS/TS API example size (minified + gzipped) 5.53 kB 12.8 kB 965 B
Performance (nested object parse) 153,787 ops/ms 1,177 ops/ms 3,562 ops/ms
Eval-free
Codegen-free (Doesn't need compiler)
Ecosystem ⭐️ ⭐️⭐️⭐️⭐️⭐️ ⭐️⭐️

Improved tree-shaking

When Valibot came out, it became a role model in terms of the JS footprint for parsing libraries. While having a similar modular design with small independent functions, I've decided to put some effort into reducing rescript-struct bundle size as much as possible. So, if we are talking of the JS/TS API example size, here are the improvements in the release:

V.5.0.1 V5.1.0 Diff
JS/TS API example size 17.3 kB 15.3 kB -2 kB
JS/TS API example size (minified + gzipped) 6.08 kB 5.53 kB -0.55 kB

JS/TS API enrichment

S.merge (Not available for ReScript users)

You can add additional fields to an object schema with the merge function.

const baseTeacherStruct = S.object({ students: S.array(S.string) });
const hasIDStruct = S.object({ id: S.string });

const teacherStruct = S.merge(baseTeacherStruct, hasIDStruct);
type Teacher = S.Output<typeof teacherStruct>; // => { students: string[], id: string }

🧠 The function will throw if the structs share keys. The returned schema also inherits the "unknownKeys" policy (strip/strict) of B.

Added missing primitive S.undefined

// empty type
S.undefined;

Alias for S.unit in ReScript API.

Advanced object struct

Sometimes you want to transform the data coming to your system. You can easily do it by passing a function to the S.object struct.

const userStruct = S.object((s) => ({
  id: s.field("USER_ID", S.number),
  name: s.field("USER_NAME", S.string),
}));

S.parseOrThrow(userStruct, {
  USER_ID: 1,
  USER_NAME: "John",
});
// => returns { id: 1, name: "John" }

// Infer output TypeScript type of the userStruct
type User = S.Output<typeof userStruct>; // { id: number; name: string }

Compared to using S.transform, the approach has 0 performance overhead. Also, you can use the same struct to transform the parsed data back to the initial format:

S.serializeOrThrow(userStruct, {
  id: 1,
  name: "John",
});
// => returns { USER_ID: 1, USER_NAME: "John" }

Advanced tuple struct

Sometimes you want to transform incoming tuples to a more convenient data-structure. To do this you can pass a function to the S.tuple struct.

const athleteStruct = S.tuple((s) => ({
  name: s.item(0, S.string),
  jerseyNumber: s.item(1, S.number),
  statistics: s.item(
    2,
    S.object({
      pointsScored: S.number,
    })
  ),
}));

type Athlete = S.Output<typeof athleteStruct>;
// type Athlete = {
//   name: string;
//   jerseyNumber: number;
//   statistics: {
//     pointsScored: number;
//   };
// }

The same as for advanced objects, you can use the same struct for transforming the parsed data back to the initial format. Also, it has 0 performance overhead and is as fast as parsing tuples without the transformation.

name

S.name(S.literal({ abc: 123 }));
// `Literal({"abc": 123})`

Used internally for readable error messages.

🧠 Subject to change

setName

const struct = S.setName(S.literal({ abc: 123 }, "Abc"));

S.name(struct);
// `Abc`

You can customise a struct name using S.setName.

Other changes

  • Fixed TS type for S.literal to support any JS value
  • Added S.Error.reason helper to get an error reason without location
  • Documented S.classify/S.name/S.setName for ReScript users

v5.0.1

10 Sep 15:15
Compare
Choose a tag to compare
  • Fixed serializing for object and tuple structs used multiple times as a field struct #63
  • Documentation update: Cleaned up highlights and fixed package size

Full Changelog: v5.0.0...v5.0.1

v5.0.0

09 Sep 19:28
Compare
Choose a tag to compare

API changes

Trailing unit removal

Also, the primitive structs are not functions anymore 🙌
The structs became much more ergonomic to write:

// Primitive structs
- S.string()
+ S.string

// Built-in refinements
- S.string()->S.String.email()
+ S.string->S.String.email

Exciting literal rework

Creating a literal struct became much easier:

- S.literal(String("foo"))
+ S.literal("foo")

You can pass literally any value to S.literal, and it'll work. Even a tagged variant. That's because now they support any Js value, not only primitives as before:

// Uses Number.isNaN to match NaN literals
let nanStruct = S.literal(Float.Constants.nan)->S.variant(_ => ()) // For NaN literals, I recommend adding S.variant to transform it into unit. It's better than having it as a float

// Supports symbols and BigInt
let symbolStruct = S.literal(Symbol.asyncIterator)
let twobigStruct = S.literal(BigInt.fromInt(2))

// Supports variants and polymorphic variants
let appleStruct = S.literal(#apple)
let noneStruct = S.literal(None)

// Does a deep check for objects and arrays
let cliArgsStruct = S.literal(("help", "lint"))

// Supports functions and literally any Js values matching them with the === operator
let fn = () => "foo"
let fnStruct = S.literal(fn)
let weakMap = WeakMap.make()
let weakMapStruct = S.literal(weakMap)

Other literal changes

  • Added S.Literal module, which provides useful helpers to work with literals
  • S.literal type moved to S.Literal.t and now supports all Js values
  • S.literalVariant removed in favor of S.literal and S.variant

Object enhancements

Updated Object parser type check. Now it uses input && input.constructor === Object check instead of input && typeof input === "object" && !Array.isArray(input). You can use S.custom to deal with class instances. Let me know if it causes problems.

The S.field function is removed and becomes a method on the object factory ctx. It is more reliable and has fewer letters to type.

- let pointStruct = S.object(o => {
-   x: o->S.field("x", S.int()),
-   y: o->S.field("y", S.int()),
- })
+ let pointStruct = S.object(s => {
+   x: s.field("x", S.int),
+   y: s.field("y", S.int),
+ })

🧠 You can notice that the o arg is now called s. The same changes are in the docs as well. That's a new suggested convention to call rescript-struct ctx objects with the s letter. It's not required to follow, but I think it's nice to follow the same style.

To make life easier, I've rethought the previously removed S.discriminant function, redesigned and returned it back as the tag method:

- let struct = S.object(o => {
-   ignore(o->S.field("key", S.literal(String("value"))))
-   Circle({
-     radius: o->S.field("radius", S.float()),
-   })
- })
+ let struct = S.object(s => {
+   s.tag("kind", "circle")
+   Circle({
+     radius: s.field("radius", S.float),
+   })
+ })

Also, there's another lil helper for setting default values for fields:

- let struct = S.object(o => {
-   name: o->S.field("name", S.option(S.string)->S.default(() => "Unknown")),
- })
+ let struct = S.object(s => {
+   name: s.fieldOr("name", S.string, "Unknown"),
+ })

And yeah, it became 50% faster 🚀

Tuple keeps up with trends

Say goodbye to the tuple0-tuple10 and untyped Tuple.factory. Now you can create tuples like objects: type-safe, without limitation on size and with built-in transformation.

- let pointStruct =
-   S.tuple3(S.literalVariant(String("point"), ()), S.int(), S.int())->S.transform(
-     ~parser=((), x, y) => {x, y},
-     ~serializer=({x, y}) => ((), x, y),
-     (),
-   )
+ let pointStruct = S.tuple(s => {
+   // The same `tag` method as in S.object
+   s.tag(0, "point")
+   {
+     x: s.item(1, S.int),
+     y: s.item(2, S.int),
+   }
+ })

🧠 The S.tuple1-S.tuple3 are still available for convenience.

Big error clean up

I've turned the error into an instance of Error. So now, when it's raised and not caught, it will be logged nicely with a readable error message.

At the same time, it's still compatible with the ReScript exn type and can be caught using S.Raised.

The whole list of error-related changes

  • Added S.Error.make, S.Error.raise, S.Error.code. S.Raised became private. Use S.Error.raise instead
  • Moved error types from S.Error module to the S module:
    • S.Error.t -> S.error
    • S.Error.code -> S.errorCode
    • S.Error.operation -> S.operation
  • Renamed S.Error.toString -> S.Error.message
  • Improved the type name format in the error message
  • Removed S.Result.getExn. Use ...OrRaiseWith operations. They now throw beautiful errors, so the S.Result.getExn is not needed
  • Removed S.Result.mapErrorToString
  • Updated error codes:
    • InvalidJsonStruct's payload now contains the invalid struct itself instead of the name
    • Renamed TupleSize -> InvalidTupleSize
    • Renamed UnexpectedType -> InvalidType, which now contains the failed struct and provided input instead of their names. Also, it's not returned for literals anymore, literal structs always fail with InvlidLiteral error code
    • Fixed InvalidType expected type in the error message for nullable and optional structs
    • UnexpectedValue renamed to InvlidLiteral and contains the expected literal and provided input instead of their names
    • MissingSerializer and MissingParser turned into the single InvalidOperation({description: string})

Effects redesign

Before to fail inside of an effect struct (refine/transform/preprocess), there was the S.fail function, available globally. To solve this one and other problems, the effect structs now provide a ctx object with the fail method in it:

- let intToString = struct =>
-   struct->S.transform(
-     ~parser=int => int->Int.toString,
-     ~serializer=string =>
-       switch string->Int.fromString {
-       | Some(int) => int
-       | None => S.fail("Can't convert string to int")
-       },
-     (),
-   )
+ let intToString = struct =>
+   struct->S.transform(s => {
+     parser: Int.toString,
+     serializer: string =>
+       switch string->Int.fromString {
+       | Some(int) => int
+       | None => s.fail("Can't convert string to int")
+       },
+   })

You can also access the final struct state with all metadata applied and the failWithError, which previously was the S.advancedFail function:

type effectCtx<'value> = {
  struct: t<'value>,
  fail: 'a. (string, ~path: Path.t=?) => 'a,
  failWithError: 'a. error => 'a,
}

Because of the change, S.advancedTransform and S.advancedPreprocess became unnecessary and removed. You can do the same with S.transform and S.preprocess.

Another notable change happened with S.refine. Now, it accepts only one operation function applied for parser and serializer. If you need to refine only one operation, use S.transform instead.

- let shortStringStruct = S.string()->S.refine(~parser=value =>
-   if value->String.length > 255 {
-     S.fail("String can't be more than 255 characters")
-   }
- , ())
+ let shortStringStruct = S.string->S.refine(s => value =>
+   if value->String.length > 255 {
+     s.fail("String can't be more than 255 characters")
+   }
+ )

TS API empowerment

In the release, the TS API got some love and was completely redesigned to shine like never before.

It moved from zod-like API where all methods belong to one object to a tree-shakable valibot-like API. So, methods became functions, making the code tree-shakable, faster, smaller, and simpler.

import * as S from "rescript-struct";

- const userStruct = S.object({
-   username: S.string(),
- });
- userStruct.parse({ username: "Ludwig" });
+ const userStruct = S.object({
+   username: S.string,
+ });
+ S.parse(userStruct, { username: "Ludwig" });

Also, the change brought improved interop with GenType. Since S.Struct type now extends the struct type created by GenType and the interop layer for methods is removed, you can freely mix rescript-struct's TS API with code generated by GenType.

Take a look at the whole changelog at Other TS API changes.

Other changes

  • V5 requires rescript@11
  • Improved usage example
  • The library now enforces the uncurried mode internally, but it still can be used with uncurried: false
  • Added experimental support for serializing untagged variants. Please report if you come across any issues.
  • S.jsonable() -> S.json
  • S.json(struct) -> S.jsonString(struct)
  • S.parseJsonWith -> S.parseJsonStringWith
  • S.serializeToJsonWith -> S.serializeToJsonStringWith
  • S.asyncRecursive -> S.recursive. The S.recursive now works for both sync and async structs
  • Added S.toUnknown helper to cast the struct type from S.t<'any> to S.t<unknown>
  • Bug fix: S.jsonString throws an error if you pass a non-JSONable struct. It used to silently serialize to undefined instead of the expected string type
  • Bug fix: Errors happening during the operation compilation phase now have a correct path
  • Added S.Path.dynamic
  • S.deprecate doesn't make a struct optional anymore. You need to manually wrap it with S.option
  • S.default is renamed to S.Option.getOrWith. Also, now you can use S.Option.getOr
  • Improved S.name logic to print more beautiful names for built-in structs. Name is used for errors, codegen, and external tools
  • Added S.setName to be able to customize the struct name
  • The same as effect structs, the S.custom now accepts only one argument, which is a function that gets effectCtx and returns a record w...
Read more

v4.1.1

01 May 08:16
Compare
Choose a tag to compare
  • Add support for rescript@11.0.0-alpha

Full Changelog: v4.1.0...v4.1.1