Releases: DZakh/rescript-schema
v6.4.0
v6.3.0
🚀 API additions
- Added
space
option to theS.jsonString
utility schema. It allows to control the JSON format on serializing. - Added
S.nullable
schema (orS.nullish
in JS/TS API). Nullable/Nullish schemas will accept bothundefined
andnull
💥 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
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
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, whileS.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
The package is renamed to rescript-schema
(previously rescript-struct
)
Migration guide:
- Uninstall
rescript-struct
and installrescript-schema
instead - Rename
RescriptStruct
->RescriptSchema
andrescript-struct
->rescript-schema
in yourrescript.json
/bsconfig.json
file - For JS/TS api users, rename the
S.Struct
type toS.Schema
- Change
struct
toschema
in your code if you followed the suggested naming from the documentation
Full Changelog: v5.1.1...v6.0.0
v5.1.1
v5.1.0
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
v5.0.0
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 toS.Literal.t
and now supports all Js valuesS.literalVariant
removed in favor ofS.literal
andS.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 calleds
. The same changes are in the docs as well. That's a new suggested convention to call rescript-struct ctx objects with thes
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. UseS.Error.raise
instead - Moved error types from
S.Error
module to theS
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 theS.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 withInvlidLiteral
error code - Fixed
InvalidType
expected type in the error message for nullable and optional structs UnexpectedValue
renamed toInvlidLiteral
and contains the expected literal and provided input instead of their namesMissingSerializer
andMissingParser
turned into the singleInvalidOperation({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
. TheS.recursive
now works for both sync and async structs- Added
S.toUnknown
helper to cast the struct type fromS.t<'any>
toS.t<unknown>
- Bug fix:
S.jsonString
throws an error if you pass a non-JSONable struct. It used to silently serialize toundefined
instead of the expectedstring
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 withS.option
S.default
is renamed toS.Option.getOrWith
. Also, now you can useS.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 getseffectCtx
and returns a record w...
v4.1.1
- Add support for
rescript@11.0.0-alpha
Full Changelog: v4.1.0...v4.1.1