From 65d11409541c4226e6251a8f0099184b18ae70f9 Mon Sep 17 00:00:00 2001 From: Dmitry Zakharov Date: Sun, 15 Dec 2024 22:02:05 +0400 Subject: [PATCH] Update documentation for rescript --- CONTRIBUTING.md | 21 +- README.md | 18 +- docs/js-usage.md | 2 +- docs/rescript-usage.md | 591 ++++++++++++++++++----------------------- package.json | 2 +- pnpm-lock.yaml | 9 +- 6 files changed, 282 insertions(+), 361 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc2f114b..a3e56b7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,10 +60,10 @@ https://bundlejs.com/ `rescript-schema` ```ts -import * as S from "github:DZakh/rescript-schema/artifacts/packages/artifacts/dist/S.mjs"; +import * as S from "rescript-schema@9.0.0-rc.2"; // Create login schema with email and password -const loginSchema = S.object({ +const loginSchema = S.schema({ email: S.email(S.string), password: S.stringMinLength(S.string, 8), }); @@ -72,19 +72,22 @@ const loginSchema = S.object({ type LoginData = S.Output; // { email: string; password: string } // Throws the S.Error(`Failed parsing at ["email"]. Reason: Invalid email address`) -loginSchema.parseOrThrow({ email: "", password: "" }); +S.parseOrThrow({ email: "", password: "" }, loginSchema); // Returns data as { email: string; password: string } -loginSchema.parseOrThrow({ - email: "jane@example.com", - password: "12345678", -}); +S.parseOrThrow( + { + email: "jane@example.com", + password: "12345678", + }, + loginSchema +); ``` valibot ```ts -import * as v from "valibot"; // 1.21 kB +import * as v from "valibot@0.42.1"; // 1.21 kB // Create login schema with email and password const LoginSchema = v.object({ @@ -105,7 +108,7 @@ v.parse(LoginSchema, { email: "jane@example.com", password: "12345678" }); zod ```ts -export * as z from "zod"; // 1.21 kB +import * as z from "zod"; // Create login schema with email and password const LoginSchema = z.object({ diff --git a/README.md b/README.md index 9573846b..951c069c 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ Highlights: - The **fastest** parsing and validation library in the entire JavaScript ecosystem ([benchmark](https://moltar.github.io/typescript-runtime-type-benchmarks/)) - Small JS footprint & tree-shakable API ([Comparison with Zod and Valibot](#comparison)) - Describe transformations in a schema without a performance loss -- Can reverse transformed values to the initial format (serializing) -- Error messages are detailed and easy to understand +- Reverse schema and convert values to the initial format (serializing) +- Detailed and easy to understand error messages - Support for asynchronous transformations -- Immutable API with both result and exception-based operations +- Immutable API with 100+ different operation combinations - Easy to create _recursive_ schema -- Opt-in strict mode for object schema to prevent excessive fields. And many more built-in helpers +- Opt-in strict mode for object schema to prevent unknown fields with ability to change it for the whole project - Opt-in ReScript PPX to generate schema from type definition Also, it has declarative API allowing you to use **rescript-schema** as a building block for other tools, such as: @@ -46,12 +46,12 @@ Besides the individual bundle size, the overall size of the library is also sign At the same time **rescript-schema** is the fastest composable validation library in the entire JavaScript ecosystem. This is achieved because of the JIT approach when an ultra optimized validator is created using `eval`. -| | rescript-schema@9.0.0 | Zod@3.23.8 | Valibot@0.42.1 | +| | rescript-schema@9.0.0 | Zod@3.24.1 | Valibot@0.42.1 | | ---------------------------------------- | --------------------- | --------------- | -------------- | -| **Total size** (minified + gzipped) | 10.8 kB | 14.2 kB | 10.5 kB | -| **Example size** (minified + gzipped) | 4.38 kB | 12.9 kB | 1.22 kB | -| **Parse with the same schema** | 100,070 ops/ms | 1,325 ops/ms | 3,946 ops/ms | -| **Create schema & parse once** | 195 ops/ms | 121 ops/ms | 2,583 ops/ms | +| **Total size** (minified + gzipped) | 11 kB | 14.8 kB | 10.5 kB | +| **Example size** (minified + gzipped) | 4.45 kB | 13.5 kB | 1.22 kB | +| **Parse with the same schema** | 100,070 ops/ms | 1,277 ops/ms | 3,881 ops/ms | +| **Create schema & parse once** | 179 ops/ms | 112 ops/ms | 2,521 ops/ms | | **Eval-free** | ❌ | ✅ | ✅ | | **Codegen-free** (Doesn't need compiler) | ✅ | ✅ | ✅ | | **Ecosystem** | ⭐️⭐️ | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️ | diff --git a/docs/js-usage.md b/docs/js-usage.md index 844e7aa7..5878b969 100644 --- a/docs/js-usage.md +++ b/docs/js-usage.md @@ -725,7 +725,7 @@ S.schema(false).parseOrThrow(true); ```rescript S.setGlobalConfig({ - defaultUnknownKeys: Strict, + defaultUnknownKeys: "Strict", }) ``` diff --git a/docs/rescript-usage.md b/docs/rescript-usage.md index 327c4e07..937edd37 100644 --- a/docs/rescript-usage.md +++ b/docs/rescript-usage.md @@ -28,13 +28,13 @@ - [Transform to a tuple](#transform-to-a-tuple) - [Transform to a variant](#transform-to-a-variant) - [`s.flatten`](#sflatten) - - [`s.nestedField`](#snestedfield) + - [`s.nested`](#snested) - [`Object destructuring`](#object-destructuring) - - [`Extend field with another object schema`](#extend-field-with-another-object-schema) - [`strict`](#strict) - [`strip`](#strip) + - [`deepStrict` & `deepStrip`](#deepstrict--deepstrip) - [`schema`](#schema) - - [`variant`](#variant) + - [`to`](#to) - [`union`](#union) - [Enums](#enums) - [`array`](#array) @@ -55,25 +55,15 @@ - [Transforms](#transforms) - [Preprocess](#preprocess-advanced) - [Functions on schema](#functions-on-schema) - - [`parseWith`](#parsewith) - - [`parseAnyWith`](#parseanywith) - - [`parseJsonStringWith`](#parsejsonstringwith) - - [`parseAsyncWith`](#parseasyncwith) - - [`serializeWith`](#serializewith) - - [`serializeToUnknownWith`](#serializetounknownwith) - - [`serializeToJsonStringWith`](#serializetojsonstringwith) - - [`convertAnyWith`](#convertanywith) - - [`convertAnyToJsonWith`](#convertanytojsonwith) - - [`convertAnyToJsonStringWith`](#convertanytojsonstringwith) - - [`convertAnyAsyncWith`](#convertanyasyncwith) + - [`Built-in operations`](#built-in-operations) - [`compile`](#compile) + - [`reverse`](#reverse) - [`classify`](#classify) - [`isAsync`](#isasync) - [`name`](#name) - [`setName`](#setname) - [`removeTypeValidation`](#removetypevalidation) - [Error handling](#error-handling) - - [`unwrap`](#unwrap) - [`Error.make`](#errormake) - [`Error.raise`](#errorraise) - [`Error.message`](#errormessage) @@ -97,8 +87,6 @@ Then add `rescript-schema` to `bs-dependencies` in your `rescript.json`: } ``` -> 🧠 Starting from V5 **rescript-schema** requires **rescript@11**. At the same time it works in both curried and uncurried mode. - ## Basic usage ```rescript @@ -135,35 +123,35 @@ let filmSchema = S.object(s => { // 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({ +}->S.parseOrThrow(filmSchema) +// { // id: 1., // title: "My first film", // tags: [], // rating: Restricted, // deprecatedAgeRestriction: Some(17), -// }) +// } -// 4. Transform data back using the same schema +// 4. Convert data back using the same schema { id: 2., tags: ["Loved"], title: "Sad & sed", rating: ParentalStronglyCautioned, deprecatedAgeRestriction: None, -}->S.serializeWith(filmSchema) -// Ok(%raw(`{ +}->S.reverseConvertOrThrow(filmSchema) +// { // "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 @@ -287,11 +275,11 @@ Compiled serializer code ```rescript let schema = S.string -%raw(`"Hello World!"`)->S.parseWith(schema) -// Ok("Hello World!") +"Hello World!"->S.parseOrThrow(schema) +// "Hello World!" ``` -The `string` schema represents a data that is a string. It can be further constrainted with the following utility methods. +The `S.string` schema represents a data that is a string. It can be further constrainted with the following utility methods. **rescript-schema** includes a handful of string-specific refinements and transforms: @@ -327,37 +315,23 @@ let datetimeSchema = S.string->S.datetime // The datetimeSchema has the type S.t // String is transformed to the Date.t instance -%raw(`"2020-01-01T00:00:00Z"`)->S.parseWith(datetimeSchema) // pass -%raw(`"2020-01-01T00:00:00.123Z"`)->S.parseWith(datetimeSchema) // pass -%raw(`"2020-01-01T00:00:00.123456Z"`)->S.parseWith(datetimeSchema) // pass (arbitrary precision) -%raw(`"2020-01-01T00:00:00+02:00"`)->S.parseWith(datetimeSchema) // fail (no offsets allowed) +"2020-01-01T00:00:00Z"->S.parseOrThrow(datetimeSchema) // pass +"2020-01-01T00:00:00.123Z"->S.parseOrThrow(datetimeSchema) // pass +"2020-01-01T00:00:00.123456Z"->S.parseOrThrow(datetimeSchema) // pass (arbitrary precision) +"2020-01-01T00:00:00+02:00"->S.parseOrThrow(datetimeSchema) // fail (no offsets allowed) ``` ### **`bool`** `S.t` -```rescript -let schema = S.bool - -%raw(`false`)->S.parseWith(schema) -// Ok(false) -``` - -The `bool` schema represents a data that is a boolean. +The `S.bool` schema represents a data that is a boolean. ### **`int`** `S.t` -```rescript -let schema = S.int - -%raw(`123`)->S.parseWith(schema) -// Ok(123) -``` - -The `int` schema represents a data that is an integer. +The `S.int` schema represents a data that is an integer. **rescript-schema** includes some of int-specific refinements: @@ -371,14 +345,7 @@ S.int->S.port // Invalid port `S.t` -```rescript -let schema = S.float - -%raw(`123`)->S.parseWith(schema) -// Ok(123.) -``` - -The `float` schema represents a data that is a number. +The `S.float` schema represents a data that is a number. **rescript-schema** includes some of float-specific refinements: @@ -391,14 +358,7 @@ S.float->S.floatMin(5) // Number must be greater than or equal to 5 `S.t` -```rescript -let schema = S.bigint - -%raw(`123n`)->S.parseWith(schema) -// Ok(123n) -``` - -The `bigint` schema represents a data that is a BigInt. +The `S.bigint` schema represents a data that is a BigInt. ### **`option`** @@ -407,13 +367,13 @@ The `bigint` schema represents a data that is a BigInt. ```rescript let schema = S.option(S.string) -%raw(`"Hello World!"`)->S.parseWith(schema) -// Ok(Some("Hello World!")) -%raw(`undefined`)->S.parseWith(schema) -// Ok(None) +"Hello World!"->S.parseOrThrow(schema) +// Some("Hello World!") +%raw(`undefined`)->S.parseOrThrow(schema) +// None ``` -The `option` schema represents a data of a specific type that might be undefined. +The `S.option` schema represents a data of a specific type that might be undefined. ### **`Option.getOr`** @@ -422,10 +382,10 @@ The `option` schema represents a data of a specific type that might be undefined ```rescript let schema = S.option(S.string)->S.Option.getOr("Hello World!") -%raw(`undefined`)->S.parseWith(schema) -// Ok("Hello World!") -%raw(`"Goodbye World!"`)->S.parseWith(schema) -// Ok("Goodbye World!") +%raw(`undefined`)->S.parseOrThrow(schema) +// "Hello World!" +"Goodbye World!"->S.parseOrThrow(schema) +// "Goodbye World!" ``` The `Option.getOr` augments a schema to add transformation logic for default values, which are applied when the input is undefined. @@ -439,10 +399,10 @@ The `Option.getOr` augments a schema to add transformation logic for default val ```rescript let schema = S.option(S.array(S.string))->S.Option.getOrWith(() => ["Hello World!"]) -%raw(`undefined`)->S.parseWith(schema) -// Ok(["Hello World!"]) -%raw(`["Goodbye World!"]`)->S.parseWith(schema) -// Ok(["Goodbye World!"]) +%raw(`undefined`)->S.parseOrThrow(schema) +// ["Hello World!"] +["Goodbye World!"]->S.parseOrThrow(schema) +// ["Goodbye World!"] ``` Also you can use `Option.getOrWith` for lazy evaluation of the default value. @@ -454,15 +414,15 @@ Also you can use `Option.getOrWith` for lazy evaluation of the default value. ```rescript let schema = S.null(S.string) -%raw(`"Hello World!"`)->S.parseWith(schema) -// Ok(Some("Hello World!")) -%raw(`null`)->S.parseWith(schema) -// Ok(None) +"Hello World!"->S.parseOrThrow(schema) +// Some("Hello World!") +%raw(`null`)->S.parseOrThrow(schema) +// None ``` -The `null` schema represents a data of a specific type that might be null. +The `S.null` schema represents a data of a specific type that might be null. -> 🧠 Since `null` transforms value into `option` type, you can use `Option.getOr`/`Option.getOrWith` for it as well. +> 🧠 Since `S.null` transforms value into `option` type, you can use `Option.getOr`/`Option.getOrWith` for it as well. ### **`nullable`** @@ -471,30 +431,23 @@ The `null` schema represents a data of a specific type that might be null. ```rescript let schema = S.nullable(S.string) -%raw(`"Hello World!"`)->S.parseWith(schema) -// Ok(Some("Hello World!")) -%raw(`null`)->S.parseWith(schema) -// Ok(None) -%raw(`undefined`)->S.parseWith(schema) -// Ok(None) +"Hello World!"->S.parseOrThrow(schema) +// Some("Hello World!") +%raw(`null`)->S.parseOrThrow(schema) +// None +%raw(`undefined`)->S.parseOrThrow(schema) +// None ``` -The `nullable` schema represents a data of a specific type that might be null or undefined. +The `S.nullable` schema represents a data of a specific type that might be null or undefined. -> 🧠 Since `nullable` transforms value into `option` type, you can use `Option.getOr`/`Option.getOrWith` for it as well. +> 🧠 Since `S.nullable` transforms value into `option` type, you can use `Option.getOr`/`Option.getOrWith` for it as well. ### **`unit`** `S.t` -```rescript -let schema = S.unit - -%raw(`undefined`)->S.parseWith(schema) -// Ok() -``` - -The `unit` schema factory is an alias for `S.literal()`. +The `S.unit` schema is an alias for `S.literal()`. ### **`literal`** @@ -529,7 +482,7 @@ let weakMap = WeakMap.make() let weakMapSchema = S.literal(weakMap) ``` -The `literal` schema enforces that a data matches an exact value during parsing and serializing. +The `S.literal` schema enforces that a data matches an exact value during parsing and serializing. ### **`object`** @@ -548,8 +501,8 @@ let pointSchema = S.object(s => { }) // It can be used both for parsing and serializing -{"x": 1, "y": -4}->S.parseAnyWith(pointSchema) -{x: 1, y: -4}->S.serializeWith(pointSchema) +{"x": 1, "y": -4}->S.parseOrThrow(pointSchema) +{x: 1, y: -4}->S.reverseConvertOrThrow(pointSchema) ``` The `object` schema represents an object value, that can be transformed into any ReScript value. Here are some examples: @@ -567,8 +520,13 @@ let schema = S.object(s => { name: s.field("USER_NAME", S.string), }) -%raw(`{"USER_ID":1,"USER_NAME":"John"}`)->S.parseWith(schema) // Ok({id: 1, name: "John"}) -{id: 1, name: "John"}->S.serializeWith(schema) // Ok({"USER_ID":1,"USER_NAME":"John"}) +{ + "USER_ID": 1, + "USER_NAME": "John", +}->S.parseOrThrow(schema) +// {id: 1, name: "John"} +{id: 1, name: "John"}->S.reverseConvertOrThrow(schema) +// {"USER_ID": 1, "USER_NAME": "John"} ``` #### Transform to a structurally typed object @@ -587,15 +545,15 @@ let schema = S.object(s => { // It will have the S.t<(int, string)> type let schema = S.object(s => (s.field("USER_ID", S.int), s.field("USER_NAME", S.string))) -%raw(`{"USER_ID":1,"USER_NAME":"John"}`)->S.parseWith(schema) -// Ok((1, "John")) +{"USER_ID":1,"USER_NAME":"John"}->S.parseOrThrow(schema) +// (1, "John") ``` The same schema also works for serializing: ```rescript -(1, "John")->S.serializeWith(schema) -// Ok(%raw(`{"USER_ID":1,"USER_NAME":"John"}`)) +(1, "John")->S.reverseConvertOrThrow(schema) +// {"USER_ID":1,"USER_NAME":"John"} ``` #### Transform to a variant @@ -611,11 +569,11 @@ let schema = S.object(s => { }) }) -%raw(`{ +{ "kind": "circle", "radius": 1, -}`)->S.parseWith(schema) -// Ok(Circle({radius: 1})) +}->S.parseOrThrow(schema) +// Circle({radius: 1}) ``` For values whose runtime representation matches your schema, you can use the less verbose `S.schema`. Under the hood, it'll create the same `S.object` schema from the example above. @@ -635,11 +593,11 @@ let schema = S.schema(s => Circle({ You can use the schema for parsing as well as serializing: ```rescript -Circle({radius: 1})->S.serializeWith(schema) -// Ok(%raw(`{ +Circle({radius: 1})->S.reverseConvertOrThrow(schema) +// { // "kind": "circle", // "radius": 1, -// }`)) +// } ``` #### `s.flatten` @@ -648,7 +606,7 @@ It's possible to spread/flatten an object schema in another object schema, allow ```rescript type entityData = { - name: string, + name: option, age: int, } type entity = { @@ -657,7 +615,7 @@ type entity = { } let entityDataSchema = S.object(s => { - name: s.field("name", S.string), + name: s.fieldOr("name", S.string, "Unknown"), age: s.field("age", S.int), }) let entitySchema = S.object(s => { @@ -670,7 +628,7 @@ let entitySchema = S.object(s => { }) ``` -#### `s.nestedField` +#### `s.nested` A nice way to parse nested fields: @@ -678,12 +636,14 @@ A nice way to parse nested fields: let schema = S.object(s => { { id: s.field("id", S.string), - name: s.nestedField("data", "name", S.string) - age: s.nestedField("data", "name", S.int), + name: s.nested("data").fieldOr("name", S.string, "Unknown") + age: s.nested("data").field("age", S.int), } }) ``` +The `s.nested` returns a complete `S.Object.s` context of the nested object, which you can use to define nested schema without any limitations. + #### Object destructuring It's possible to destructure object field schemas inside of definition. You could also notice it in the `s.flatten` example 😁 @@ -699,28 +659,7 @@ let entitySchema = S.object(s => { }) ``` -> 🧠 While the example with `s.flatten` expect an object with the type `{id: string, name: string, age: int}`, the example above and with `s.nestedField` will expect an object with the type `{id: string, data: {name: string, age: int}}`. - -#### Extend field with another object schema - -You can define object field multiple times to extend it with more fields: - -```rescript -let entitySchema = S.object(s => { - let {name, age} = s.field("data", entityDataSchema) - let additionalData = s.field("data", s => { - "friends": s.field("friends", S.array(S.string)) - }) - { - id: s.field("id", S.string), - name, - age, - friends: additionalData["friends"], - } -}) -``` - -> 🧠 Destructuring works only with not-transformed object schemas. Be careful, since it's not protected by typesystem. +> 🧠 While the example with `s.flatten` expect an object with the type `{id: string, name: option, age: int}`, the example above as well as for `s.nested` will expect an object with the type `{id: string, data: {name: option, age: int}}`. ### **`strict`** @@ -730,18 +669,22 @@ let entitySchema = S.object(s => { // Represents an object without fields let schema = S.object(_ => ())->S.strict -%raw(`{ +{ "someField": "value", -}`)->S.parseWith(schema) -// Error({ -// code: ExcessField("someField"), -// operation: Parse, -// path: S.Path.empty, -// }) +}->S.parseOrThrow(schema) +// throws S.error with the message: `Failed parsing at root. Reason: Encountered disallowed excess key "unknownKey" on an object` ``` By default **rescript-schema** silently strips unrecognized keys when parsing objects. You can change the behaviour to disallow unrecognized keys with the `S.strict` function. +If you want to change it for all schemas in your app, you can use `S.setGlobalConfig` function: + +```rescript +S.setGlobalConfig({ + defaultUnknownKeys: Strict, +}) +``` + ### **`strip`** `S.t<'value> => S.t<'value>` @@ -750,14 +693,31 @@ By default **rescript-schema** silently strips unrecognized keys when parsing ob // Represents an object with any fields let schema = S.object(_ => ())->S.strip -%raw(`{ +{ "someField": "value", -}`)->S.parseWith(schema) -// Ok() +}->S.parseOrThrow(schema) +// () ``` You can use the `S.strip` function to reset a object schema to the default behavior (stripping unrecognized keys). +### **`deepStrict` & `deepStrip`** + +Both `S.strict` and `S.strip` are applied for the first level of the object schema. If you want to apply it for all nested schemas, you can use `S.deepStrict` and `S.deepStrip` functions. + +```rescript +let schema = S.schema(s => + { + "bar": { + "baz": s.matches(S.string), + } + } +) + +schema->S.strict // {"baz": string} will still allow unknown keys +schema->S.deepStrict // {"baz": string} will not allow unknown keys +``` + ### **`schema`** `(S.Schema.s => 'value) => S.t<'value>` @@ -807,15 +767,15 @@ type shape = Circle({radius: float}) | Square({x: float}) | Triangle({x: float, // It will have the S.t type let schema = S.float->S.to(radius => Circle({radius: radius})) -%raw(`1`)->S.parseWith(schema) -// Ok(Circle({radius: 1.})) +1->S.parseOrThrow(schema) +// Circle({radius: 1.}) ``` The same schema also works for serializing: ```rescript -Circle({radius: 1})->S.serializeWith(schema) -// Ok(%raw(`1`)) +Circle({radius: 1})->S.reverseConvertOrThrow(schema) +// 1 ``` ### **`union`** @@ -860,19 +820,19 @@ let shapeSchema = S.union([ ``` ```rescript -%raw(`{ +{ "kind": "circle", "radius": 1, -}`)->S.parseWith(shapeSchema) -// Ok(Circle({radius: 1.})) +}->S.parseOrThrow(shapeSchema) +// Circle({radius: 1.}) ``` ```rescript -Square({x: 2.})->S.serializeWith(shapeSchema) -// Ok({ +Square({x: 2.})->S.reverseConvertOrThrow(shapeSchema) +// { // "kind": "square", // "x": 2, -// }) +// } ``` #### Enums @@ -888,8 +848,8 @@ let schema = S.union([ S.literal(Loss), ]) -%raw(`"draw"`)->S.parseWith(schema) -// Ok(Draw) +"draw"->S.parseOrThrow(schema) +// Draw ``` Also, you can use `S.enum` as a shorthand for the use case above. @@ -905,11 +865,11 @@ let schema = S.enum([Win, Draw, Loss]) ```rescript let schema = S.array(S.string) -%raw(`["Hello", "World"]`)->S.parseWith(schema) -// Ok(["Hello", "World"]) +["Hello", "World"]->S.parseOrThrow(schema) +// ["Hello", "World"] ``` -The `array` schema represents an array of data of a specific type. +The `S.array` schema represents an array of data of a specific type. **rescript-schema** includes some of array-specific refinements: @@ -926,11 +886,11 @@ S.array(itemSchema)->S.arrayLength(5) // Array must be exactly 5 items long ```rescript let schema = S.list(S.string) -%raw(`["Hello", "World"]`)->S.parseWith(schema) -// Ok(list{"Hello", "World"}) +["Hello", "World"]->S.parseOrThrow(schema) +// list{"Hello", "World"} ``` -The `list` schema represents an array of data of a specific type which is transformed to ReScript's list data-structure. +The `S.list` schema represents an array of data of a specific type which is transformed to ReScript's list data-structure. ### **`tuple`** @@ -952,11 +912,11 @@ let pointSchema = S.tuple(s => { }) // It can be used both for parsing and serializing -%raw(`["point", 1, -4]`)->S.parseWith(pointSchema) -{ x: 1, y: -4 }->S.serializeWith(pointSchema) +["point", 1, -4]->S.parseOrThrow(pointSchema) +{ x: 1, y: -4 }->S.reverseConvertOrThrow(pointSchema) ``` -The `tuple` schema represents that a data is an array of a specific length with values each of a specific type. +The `S.tuple` schema represents that a data is an array of a specific length with values each of a specific type. For short tuples without the need for transformation, there are wrappers over `S.tuple`: @@ -967,8 +927,8 @@ For short tuples without the need for transformation, there are wrappers over `S ```rescript let schema = S.tuple3(S.string, S.int, S.bool) -%raw(`["a", 1, true]`)->S.parseWith(schema) -// Ok("a", 1, true) +%raw(`["a", 1, true]`)->S.parseOrThrow(schema) +// ("a", 1, true) ``` ### **`dict`** @@ -978,11 +938,11 @@ let schema = S.tuple3(S.string, S.int, S.bool) ```rescript let schema = S.dict(S.string) -%raw(`{ +{ "foo": "bar", "baz": "qux", -}`)->S.parseWith(schema) -// Ok(Dict.fromArray([("foo", "bar"), ("baz", "qux")])) +}->S.parseOrThrow(schema) +// dict{foo: "bar", baz: "qux"} ``` The `dict` schema represents a dictionary of data of a specific type. @@ -994,10 +954,11 @@ The `dict` schema represents a dictionary of data of a specific type. ```rescript let schema = S.unknown -%raw(`"Hello World!"`)->S.parseWith(schema) +"Hello World!"->S.parseOrThrow(schema) +// "Hello World!" ``` -The `unknown` schema represents any data. +The `S.unknown` schema represents any data. ### **`never`** @@ -1006,12 +967,8 @@ The `unknown` schema represents any data. ```rescript let schema = S.never -%raw(`undefined`)->S.parseWith(schema) -// Error({ -// code: InvalidType({expected: S.never, received: undefined}), -// operation: Parse, -// path: S.Path.empty, -// }) +%raw(`undefined`)->S.parseOrThrow(schema) +// throws S.error with the message: `Failed parsing at root. Reason: Expected never, received undefined` ``` The `never` schema will fail parsing for every value. @@ -1023,11 +980,11 @@ The `never` schema will fail parsing for every value. ```rescript let schema = S.json(~validate=true) -`"abc"`->S.parseAnyWith(schema) -// Ok(String("abc")) +`"abc"`->S.parseOrThrow(schema) +// "abc" of type JSON.t ``` -The `json` schema represents a data that is compatible with JSON. +The `S.json` schema represents a data that is compatible with JSON. It accepts a `validate` as an argument. If it's true, then the value will be validated as valid JSON; otherwise, it unsafely casts it to the `JSON.t` type. @@ -1038,11 +995,11 @@ It accepts a `validate` as an argument. If it's true, then the value will be val ```rescript let schema = S.jsonString(S.int) -%raw(`"123"`)->S.parseWith(schema) -// Ok(123) +"123"->S.parseOrThrow(schema) +// 123 ``` -The `jsonString` schema represents JSON string containing value of a specific type. +The `S.jsonString` schema represents JSON string containing value of a specific type. ### **`describe`** @@ -1083,10 +1040,10 @@ Use `S.catch` to provide a "catch value" to be returned instead of a parsing err ```rescript let schema = S.float->S.catch(_ => 42.) -%raw(`5`)->S.parseWith(schema) -// Ok(5.) -%raw(`"tuna"`)->S.parseWith(schema) -// Ok(42.) +5->S.parseOrThrow(schema) +// 5. +"tuna"->S.parseOrThrow(schema) +// 42. ``` Also, the callback `S.catch` receives a catch context as a first argument. It contains the caught error and the initial data provided to the parse function. @@ -1117,31 +1074,27 @@ let nullableSchema = innerSchema => { if unknown === %raw(`undefined`) || unknown === %raw(`null`) { None } else { - Some(unknown->S.parseAnyWith(innerSchema)->S.unwrap) + Some(unknown->S.parseOrThrow(innerSchema)) } }, serializer: value => { switch value { | Some(innerValue) => - innerValue->S.serializeToUnknownWith(innerSchema)->S.unwrap + innerValue->S.reverseConvertOrThrow(innerSchema) | None => %raw(`null`) } }, }) } -%raw(`"Hello world!"`)->S.parseWith(schema) -// Ok(Some("Hello World!")) -%raw(`null`)->S.parseWith(schema) -// Ok(None) -%raw(`undefined`)->S.parseWith(schema) -// Ok(None) -%raw(`123`)->S.parseWith(schema) -// Error({ -// code: InvalidType({expected: S.string, received: 123}), -// operation: Parse, -// path: S.Path.empty, -// }) +"Hello world!"->S.parseOrThrow(schema) +// Some("Hello World!") +%raw(`null`)->S.parseOrThrow(schema) +// None +%raw(`undefined`)->S.parseOrThrow(schema) +// None +123->S.parseOrThrow(schema) +// throws S.error with the message: `Failed parsing at root. Reason: Expected string, received 123` ``` ### **`recursive`** @@ -1165,17 +1118,17 @@ let nodeSchema = S.recursive(nodeSchema => { ``` ```rescript -%raw(`{ +{ "Id": "1", "Children": [ {"Id": "2", "Children": []}, {"Id": "3", "Children": [{"Id": "4", "Children": []}]}, ], -}`)->S.parseWith(nodeSchema) -// Ok({ +}->S.parseOrThrow(nodeSchema) +// { // id: "1", // children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}], -// }) +// } ``` The same schema works for serializing: @@ -1184,14 +1137,14 @@ The same schema works for serializing: { id: "1", children: [{id: "2", children: []}, {id: "3", children: [{id: "4", children: []}]}], -}->S.serializeWith(nodeSchema) -// Ok(%raw(`{ +}->S.reverseConvertOrThrow(nodeSchema) +// { // "Id": "1", // "Children": [ // {"Id": "2", "Children": []}, // {"Id": "3", "Children": [{"Id": "4", "Children": []}]}, // ], -// }`)) +// } ``` You can also use asynchronous parser: @@ -1199,7 +1152,7 @@ You can also use asynchronous parser: ```rescript let nodeSchema = S.recursive(nodeSchema => { S.object(s => { - params: s.field("Id", S.string)->S.transform(_ => {asyncParser: id => () => loadParams(id)}), + params: s.field("Id", S.string)->S.transform(_ => {asyncParser: id => loadParams(~id)}), children: s.field("Children", S.array(nodeSchema)), }) }) @@ -1259,21 +1212,21 @@ let userSchema = S.string ->S.uuid ->S.transform(s => { - asyncParser: userId => () => loadUser(~userId), + asyncParser: userId => loadUser(~userId), serializer: user => user.id, }) -await %raw(`"1"`)->S.parseAsyncWith(userSchema) -// Ok({ +await "1"->S.parseAsyncOrThrow(userSchema) +// { // id: "1", // name: "John", -// }) +// } { id: "1", name: "John", -}->S.serializeWith(userSchema) -// Ok("1") +}->S.reverseConvertOrThrow(userSchema) +// "1" ``` ## Preprocess _Advanced_ @@ -1319,108 +1272,57 @@ let prepareEnvSchema = S.preprocess(_, s => { ## Functions on schema -### **`parseWith`** - -`(JSON.t, S.t<'value>) => result<'value, S.error>` - -```rescript -data->S.parseWith(userSchema) -``` - -Given any schema, you can call `parseWith` to check `data` is valid. It returns a result with valid data transformed to expected type or a **rescript-schema** error. - -### **`parseAnyWith`** - -`('any, S.t<'value>) => result<'value, S.error>` - -```rescript -data->S.parseAnyWith(userSchema) -``` - -The same as `parseWith`, but the `data` is loosened to the abstract type. - -### **`parseJsonStringWith`** - -`(string, S.t<'value>) => result<'value, S.error>` - -```rescript -json->S.parseJsonStringWith(userSchema) -``` - -The same as `parseWith`, but applies `JSON.parse` before parsing. - -### **`parseAsyncWith`** - -`(JSON.t, S.t<'value>) => promise>` - -```rescript -data->S.parseAsyncWith(userSchema) -``` - -If you use asynchronous refinements or transforms, you'll need to use `parseAsyncWith`. It will parse all synchronous branches first and then continue with asynchronous refinements and transforms in parallel. - -### **`serializeWith`** - -`('value, S.t<'value>) => result` - -```rescript -user->S.serializeWith(userSchema) -``` - -Serializes value using the transformation logic that is built-in to the schema. It returns a result with a transformed data or a **rescript-schema** error. - -> 🧠 It'll fail with JSON incompatible schema. Use S.serializeToUnknownWith if you have schema which doesn't serialize to JSON. - -### **`serializeToJsonStringWith`** - -`('value, ~space: int=?, S.t<'value>) => result` - -```rescript -user->S.serializeToJsonStringWith(userSchema) -``` +### Built-in operations -The same as `serializeToUnknownWith`, but applies `JSON.serialize` at the end. +The library provides a bunch of built-in operations that can be used to parse, convert, and assert values. -### **`convertAnyWith`** +Parsing means that the input value is validated against the schema and transformed to the expected output type. You can use the following operations to parse values: -`('any, S.t<'value>) => result<'value, S.error>` +| Operation | Interface | Description | +| ------------------------ | ---------------------------------------- | ------------------------------------------------------------- | +| S.parseOrThrow | `('any, S.t<'value>) => 'value` | Parses any value with the schema | +| S.parseJsonOrThrow | `(Js.Json.t, S.t<'value>) => 'value` | Parses JSON value with the schema | +| S.parseJsonStringOrThrow | `(string, S.t<'value>) => 'value` | Parses JSON string with the schema | +| S.parseAsyncOrThrow | `('any, S.t<'value>) => promise<'value>` | Parses any value with the schema having async transformations | -```rescript -rawUser->S.convertAnyWith(userSchema) -``` +For advanced users you can only transform to the output type without type validations. But be careful, since the input type is not checked: -The same as `parseAnyWith`, but it doesn't contain any type validations. It's useful for transforming valid data to the value format. +| Operation | Interface | Description | +| ---------------------------- | ---------------------------------------- | ------------------------------------------------------------------ | +| S.convertOrThrow | `('any, S.t<'value>) => 'value` | Converts any value to the output type | +| S.convertToJsonOrThrow | `('any, S.t<'value>) => Js.Json.t` | Converts any value to JSON | +| S.convertToJsonStringOrThrow | `('any, S.t<'value>) => string` | Converts any value to JSON string | +| S.convertAsyncOrThrow | `('any, S.t<'value>) => promise<'value>` | Converts any value to the output type having async transformations | -### **`convertAnyToJsonWith`** +Note, that in this case only type validations are skipped. If your schema has refinements or transforms, they will be applied. -`('any, S.t<'value>) => result` +Also, you can use `S.removeTypeValidation` helper to turn off type validations for the schema even when it's used with a parse operation. -```rescript -rawUser->S.convertAnyToJsonWith(userSchema) -``` +More often than converting input to output, you'll need to perform the reversed operation. It's usually called "serializing" or "decoding". The ReScript Schema has a unique mental model and provides an ability to reverse any schema with `S.reverse` which you can later use with all possible kinds of operations. But for convinence, there's a few helper functions that can be used to convert output values to the initial format: -The same as `convertAnyWith`, but the output type is `Js.Json.t`. Also, it validates that the schema is JSON compatible, otherwise it returns an error. +| Operation | Interface | Description | +| ----------------------------------- | ---------------------------------------- | --------------------------------------------------------------------- | +| S.reverseConvertOrThrow | `('value, S.t<'value>) => 'any` | Converts schema value to the output type | +| S.reverseConvertToJsonOrThrow | `('value, S.t<'value>) => Js.Json.t` | Converts schema value to JSON | +| S.reverseConvertToJsonStringOrThrow | `('value, S.t<'value>) => string` | Converts schema value to JSON string | +| S.reverseConvertAsyncOrThrow | `('value, S.t<'value>) => promise<'any>` | Converts schema value to the output type having async transformations | -### **`convertAnyToJsonStringWith`** +This is literally the same as convert operations applied to the reversed schema. -`('any, S.t<'value>) => result` +For some cases you might want to simply assert the input value is valid. For this there's `S.assertOrThrow` operation: -```rescript -rawUser->S.convertAnyToJsonStringWith(userSchema) -``` - -Validates that the schema is JSON compatible, converts valid data to the value format and serializes it to JSON string. +| Operation | Interface | Description | +| --------------- | --------------------------- | ------------------------------------- | +| S.assertOrThrow | `('any, S.t<'value>) => ()` | Asserts that the input value is valid | -### **`convertAnyAsyncWith`** - -`('any, S.t<'value>) => promise>` +All operations either return the output value or raise an exception which you can catch with `try/catch` block: ```rescript -rawUser->S.convertAnyAsyncWith(userSchema) +try true->S.parseOrThrow(schema) catch { +| S.Error.Raised(error) => Console.log(error->S.Error.message) +} ``` -Async version for the `convertAnyWith` operation. - ### **`compile`** `(S.t<'value>, ~input: input<'value, 'input>, ~output: output<'value, 'transformedOutput>, ~mode: mode<'transformedOutput, 'output>, ~typeValidation: bool=?) => 'input => 'output` @@ -1429,13 +1331,13 @@ If you want to have the most possible performance, or the built-in operations do ```rescript let fn = S.compile( - S.int, + S.string, ~input=Any, ~output=Assert, ~mode=Async, ) await fn("Hello world!") -// Ok("Hello world!") +// () ``` For example, in the example above we've created an async assert operation, which is not available by default. @@ -1443,8 +1345,8 @@ For example, in the example above we've created an async assert operation, which You can configure compiled function `input` with the following options: - `Value` - accepts `'value` of `S.t<'value>` and reverses the operation -- `Any` - accepts `'any` - `Unknown` - accepts `unknown` +- `Any` - accepts `'any` - `Json` - accepts `Js.Json.t` - `JsonString` - accepts `string` and applies `JSON.parse` before parsing @@ -1466,6 +1368,32 @@ And you can configure compiled function `typeValidation` with the following opti - `true (default)` - performs type validation - `false` - doesn't perform type validation and only converts data to the output format. Note that refines are still applied. +### **`reverse`** + +`(S.t<'value>) => S.t<'value>` + +```rescript +S.null(S.string)->S.reverse +// S.option(S.string) +``` + +```rescript +let schema = S.object(s => s.field("foo", S.string)) + +{"foo": "bar"}->S.parseOrThrow(schema) +// "bar" + +let reversed = schema->S.reverse + +"bar"->S.parseOrThrow(reversed) +// {"foo": "bar"} + +123->S.parseOrThrow(reversed) +// throws S.error with the message: `Failed parsing at root. Reason: Expected string, received 123` +``` + +Reverses the schema. This gets especially magical for schemas with transformations 🪄 + ### **`classify`** `(S.t<'value>) => S.tagged` @@ -1484,7 +1412,7 @@ This can be useful for building other tools like [`rescript-json-schema`](https: ```rescript S.string->S.isAsync // false -S.string->S.transform(_ => {asyncParser: i => () => Promise.resolve(i)})->S.isAsync +S.string->S.transform(_ => {asyncParser: i => Promise.resolve(i)})->S.isAsync // true ``` @@ -1525,8 +1453,8 @@ let schema = S.object(s => s.field("abc", S.int))->S.removeTypeValidation { "abc": 123, -}->S.parseWith(schema) // This doesn't have `if (!i || i.constructor !== Object) {` check. But field types are still validated. -// Ok(123) +}->S.parseOrThrow(schema) // This doesn't have `if (!i || i.constructor !== Object) {` check. But field types are still validated. +// 123 ``` Removes type validation for provided schema. Nested schemas are not affected. @@ -1535,41 +1463,26 @@ This can be useful to optimise `S.object` parsing when you construct the input d ## Error handling -**rescript-schema** returns a result type with error `S.error` containing detailed information about the validation problems. +**rescript-schema** throws `S.error` error containing detailed information about the validation problems. ```rescript let schema = S.literal(false) -%raw(`true`)->S.parseWith(schema) -// Error({ -// code: InvalidType({expected: S.literal(false), received: true}), -// operation: Parse, -// path: S.Path.empty, -// }) +true->S.parseOrThrow(schema) +// throws S.error with the message: `Failed parsing at root. Reason: Expected false, received true` ``` - - ### **`Error.make`** -`(~code: S.errorCode, ~operation: S.operation, ~path: S.Path.t) => S.error` +`(~code: S.errorCode, ~flag: S.flag, ~path: S.Path.t) => S.error` Creates an instance of `RescriptSchemaError` error. At the same time it's the `S.Raised` exception. @@ -1586,7 +1499,7 @@ Throws error. Since internally it's both the `S.Raised` exception and instance o ```rescript { code: InvalidType({expected: S.literal(false), received: true}), - operation: Parse, + flag: S.Flag.typeValidation, path: S.Path.empty, }->S.Error.message ``` @@ -1602,7 +1515,7 @@ Throws error. Since internally it's both the `S.Raised` exception and instance o ```rescript { code: InvalidType({expected: S.literal(false), received: true}), - operation: Parse, + flag: S.Flag.typeValidation, path: S.Path.empty, }->S.Error.reason ``` diff --git a/package.json b/package.json index c9afe01e..afdb8c30 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "ts-node": "10.9.1", "typescript": "4.9.3", "valibot": "0.42.1", - "zod": "3.23.8" + "zod": "3.24.1" }, "peerDependencies": { "rescript": "11.x" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 027c6d6d..c071efe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ importers: specifier: 0.42.1 version: 0.42.1(typescript@4.9.3) zod: - specifier: 3.23.8 - version: 3.23.8 + specifier: 3.24.1 + version: 3.24.1 packages/artifacts: devDependencies: @@ -1060,6 +1060,9 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + snapshots: '@bcoe/v8-coverage@0.2.3': {} @@ -1923,3 +1926,5 @@ snapshots: yocto-queue@1.0.0: {} zod@3.23.8: {} + + zod@3.24.1: {}