DEV Community

Dmitry Zakharov
Dmitry Zakharov

Posted on

ReScript Schema V9 - Zod-like library to the next level πŸš€

The article is an overview of changes introduced in the recently released version 9. After more than 2 years of development, the ReScript Schema library became a project I'm genuinely proud of, so I plan to start creating more content and let more people know about it. A changelog is probably not a good piece of content to start with since it requires context for it to make sense, but I hope it'll still make some of you interested. If you've never heard about ReScript Schema, here is a short highlight:

  • This is the fastest parsing and validation library in the entire JavaScript ecosystem
  • Works with plain JavaScript, TypeScript, and ReScript. You don't need to use any compiler
  • People actually use it, and there's a decent ecosystem around it

If I made you slightly interested, you can find more on the GitHub page. Don't forget to subscribe to get notifications about more content about tools I create.

In the meantime, let's start the V9 changes overview πŸ”₯

What's new in ReScript Schema V9?

ReScript Schema V9 is a big step for the library. Even though there are not many changes in the ReScript API, the version comes with a completely new mental model, an internal redesign for enhanced operations flexibility, and a drastic improvement for the JS/TS API.

New mental model 🧬

The main idea of ReScript Schema-like libraries is to create a schema with code that describes a desired data structure. Then, you can use the schema to parse data incoming to your application, derive types, create JSON-Schema, and many more. This is how other libraries, like Zod, Valibot, Superstruct, yup, etc, work.

Even though ReScript Schema is now a mature and powerful library for JS users, I originally created it specifically to parse data in ReScript applications, with the core functionality to automatically transform data to the ReScript representation and back to the original format. This is how the advanced object API was born - the primary tool of ReScript Schema up till V9.

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",
    S.union([
      S.literal(GeneralAudiences),
      S.literal(ParentalGuidanceSuggested),
      S.literal(ParentalStronglyCautioned),
      S.literal(Restricted),
    ]),
  ),
  deprecatedAgeRestriction: s.field("Age", S.option(S.int)->S.deprecate("Use rating instead")),
})
Enter fullscreen mode Exit fullscreen mode

Here, you can see features like declarative validation, fields renaming, and automatic transformation/mapping in a single schema definition. And the schema will produce the most optimized function to parse or convert your value back to the original format using eval.

What's changed and why?

As I said before, initially, the library covered two prominent use cases: parsing & serializing. But with time, when ReScript Schema got used more extensively, it wasn't enough anymore. For example, you'd want only to apply transformations without validation, validate the data before serializing, assert with the most performance without allocations for output value, or parse JSON data, transform it, and convert the result to JSON again. And V9 introduces the S.compile function to create custom operations with 100 different combinations πŸ’ͺ

To make things even more flexible, all operations became one-directional. This might be slightly confusing because it means there's no serializing anymore. But V9 comes with a new, powerful concept of reversing your schema. Since we have 50 different one-directional operations, we now have 50 more for the opposite direction. So I hope it's a good explanation of why serializeOrRaiseWith was removed.

To keep the DX on the same level, besides S.compile + S.reverse functions, there are some built-in operations like reverseConvertOrThrow, which is equivalent to the removed serializeOrRaiseWith.

Another hidden power of the reverse feature is that now it's possible to get the type information of the schema output, allowing one to compile even more optimized schemas and build even more advanced tools.

TS to the moon πŸš€

For me, the custom compilation and reverse mental model are the biggest changes, but if you're a TypeScript user, you'll find another one to be more exciting in the v9 release. And yes, I know there are pretty many breaking changes, but creating a schema was never as smooth.

Before:

const shapeSchema = S.union([
  S.object({
    kind: S.literal("circle"),
    radius: S.number,
  }),
  S.object({
    kind: S.literal("triangle"),
    x: S.number,
    y: S.number,
  }),
]);
Enter fullscreen mode Exit fullscreen mode

After:

const shapeSchema = S.union([
  {
    kind: "circle" as const,
    radius: S.number,
  },
  {
    kind: "triangle" as const, // Or you can have S.schema("triangle")
    x: S.number,
    y: S.number,
  },
]);
Enter fullscreen mode Exit fullscreen mode

In V9 you don't need to care whether you work with an object, tuple, or a literal - simply use S.schema for all cases. If you have an enum, discriminated union, or some variadic union - simply use S.union. There's a single API, and ReScript Schema will automatically compile the most optimized operation for you.

Clean up built-in operations 🧹

If you're familiar with ReScript Schema's older versions, the built-in operations look somehow different. For example, serializeOrRaiseWith became reverseConvertOrThrow. So there are changes you should know about:

  • All operations are now throwing; there's no result-based API. As an alternative for ReScript I recommend using try/catch with the S.Raised exception. For TS users, there's a new S.safe API for convenient error handling.
  • TS and ReScript API are now the same, which is easier to maintain, interop between languages and work in mixed codebases.
  • The Raise suffix is renamed to Throw since this makes more sense in Js ecosystems, and there are plans to rename the raise function to throw in the next ReScript versions.
  • The With suffix is removed to save some space. Note that the operation is still data-first, and the schema should be a second argument.

Other changes you should know about πŸ‘€

In the release, S.schema got a noticeable boost. Besides becoming TS users' main API now, I recommend using it in ReScript by default when you don't need transformations. Previously, it was simply a wrapper over advanced S.object and S.tuple, but it got its own implementation, which allows creating schemas faster, as well as including optimizations for compiled operations:

  • If you use S.schema for strict objects without transformed fields, the incoming object won't be recreated.
  • If you use S.schema for tuples without transformed items, the incoming array won't be recreated.

Also, you can pass the object schema created with S.schema to the new Advanced Object API for nested fields coming in as a replacement for s.nestedField:

let entityDataSchema = S.schema(s => {
 name: s.matches(S.string),
 age: s.matches(S.int),
})
let entitySchema = S.object(s => {
  // Schema created with S.object wouldn't work here πŸ‘Œ
  let {name, age} = s.nested("data").flatten(entityDataSchema)
 {
 id: s.nested("data").field("id", S.string),
 name,
 age,
 }
})
Enter fullscreen mode Exit fullscreen mode

Besides the runtime improvements for operations of S.schema, there are a few more, which were possible thanks to the ability to know the output type information:

  • The union of objects will use literal fields to choose which schema it should use for parsing. Previously, it used to run operations for every schema of the union and return the result of the first non-failing one. The approach was slow, provided worse error messages, and didn't work for reversed operations. In V9 all the problems are solved.
  • Improved optional operations by doing the Caml_option.valFromOption call only when it's truly needed.

As a cherry on top of the cake, schema names became more readable, making error messages even better:

-Failed parsing at root. Reason: Expected Object({"foo":Option(String)}), received 123
+Failed parsing at root. Reason: Expected { foo: string | undefined; }, received 123
Enter fullscreen mode Exit fullscreen mode

Thanks for reading the high-level overview of the release. Contact me on GitHub or X if you have any questions 😁 And enjoy using ReScript Schema πŸš€

Top comments (0)