[ANN] Big ReScript Struct V3 release

Object redesign

One of the most exciting features of the release is the redesigned object struct factory, which has a much cleaner and more intuitive API. This makes it easy to use without the need for additional transform steps, with a risk of messing up the order of tuple items.

type point = {
  x: int,
  y: int,
}

// V2
let pointStruct =
  S.object2(. ("X", S.int()), ("Y", S.int()))->S.transform(~parser=(x, y) => {x, y}, ())

// V3
let pointStruct = S.object(o => {
  x: o->S.field("X", S.int()),
  y: o->S.field("Y", S.int()),
})

The API also does not have a limitation on the number of fields. And more…

Serializing out of the box

If you are familiar with JSON combinator libraries, you may notice that the API looks similar. However, there is actually a significant difference. Because of the library declarative nature, you can use the same struct not only for parsing, but also for serializing.

{
  x: 1,
  y: 2,
}->S.serializeWith(pointStruct)
Ok({
  "X": 1,
  "Y": 2,
})

Object transformation

In the example above, you can see that we transformed the object by renaming its fields. However, you’re not limited to just renaming fields – you can also change the entire structure of the described object. For example, you can transform it to a variant:

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

Serializing still works

You can find more examples in the documentaion.

Discriminant

In order to make it work, some magic is involved that adds limitations. And the limitation is that S.field does not return a value directly, but instead returns a pointer to where the value should be placed. All fields must be returned from the definition function in the order they were called. If you don’t need a particular field in the result object, use S.discriminant instead.

It is quite common for unions to have S.discriminant. But it’s less than a problem, but a blessing. Below is an example of how it’d be written in V2 versus V3. What’s important, serializing still works.

Performance boost

According to the benchmark, rescript-struct has received a significant performance boost in addition to API improvements, making it the fastest parsing library in the entire JavaScript ecosystem.

Here’s the comparison with V2:

Benchmark suite Previous: V2 Current: V3 Improvement
String struct creation 10115862 ops/sec (±0.88%) 685939022 ops/sec (±0.45%) 6780%
Parse string 170396181 ops/sec (±1.17%) 702640945 ops/sec (±1.05%) 416%
Serialize string 168348005 ops/sec (±0.87%) 700682666 ops/sec (±0.81%) 416%
Advanced object struct creation 278679 ops/sec (±1.02%) 247228 ops/sec (±0.37%) 88%
Parse advanced object 2284826 ops/sec (±0.92%) 17054753 ops/sec (±0.49%) 769%
Parse advanced strict object 1536769 ops/sec (±0.85%) 11330788 ops/sec (±0.22%) 714%
Serialize advanced object 1883972 ops/sec (±0.91%) 31246478 ops/sec (±0.60%) 1658%

Recursive structs

The release introduced the ability to create recursive structs. Which also works with asynchronous operations:

type rec node = {
  id: string,
  children: array<node>,
}

let nodeStruct = S.asyncRecursive(nodeStruct => {
  S.object(
    o => {
      id: o->S.field("Id", S.string())->S.asyncRefine(~parser=checkIsExistingNode, ()),
      children: o->S.field("Children", S.array(nodeStruct)),
    },
  )
})
await %raw(`{
  "Id": "1",
  "Children": [
    {"Id": "2", "Children": []},
    {"Id": "3", "Children": [{"Id": "4", "Children": []}]},
  ],
}`)->S.parseAsyncWith(nodeStruct)

Ok({
  id: "1",
  children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}],
})

One great aspect of the example above is that it uses parallelism to make four requests to check for the existence of nodes.

JS-friendly API

The cherry on the cake for this release is the JS-friendly API with TypeScript support. If you have partially adopted ReScript in your codebase, you can start using rescript-struct in your JS and TypeScript code as well.

import * as S from "rescript-struct";

const User = S.object({
  username: S.string(),
});

User.parse({ username: "Ludwig" });

// extract the inferred type
type User = S.Infer<typeof User>;
// { username: string }

The library is mainly designed with a focus on ReScript. And support for JS/TS will be secondary. You can now use the .d.ts file as an API reference.

Credits

I want to give a big thank you to @nkrkv and @hoichi for helping me sanity-check my ideas and providing suggestions for improvements during the development of the version.

All changes

The list with all changes in the release note on github.

14 Likes

Massive! :heart: Cannot wait for a chance to try it in real tasks. The new object transforms should remove so much boilerplate.

Thanks for the library!

1 Like

Not to turn this thread into a circle jerk, but I was already impressed by the V2 API design, and the V3 managed to improved it even further. Great job and thank you! :clap:

3 Likes