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.emailExciting 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.Literalmodule, which provides useful helpers to work with literals S.literaltype moved toS.Literal.tand now supports all Js valuesS.literalVariantremoved in favor ofS.literalandS.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
oarg 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 thesletter. 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.tuple3are 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.Raisedbecame private. UseS.Error.raiseinstead - Moved error types from
S.Errormodule to theSmodule:S.Error.t->S.errorS.Error.code->S.errorCodeS.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...OrRaiseWithoperations. They now throw beautiful errors, so theS.Result.getExnis 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 withInvlidLiteralerror code - Fixed
InvalidTypeexpected type in the error message for nullable and optional structs UnexpectedValuerenamed toInvlidLiteraland contains the expected literal and provided input instead of their namesMissingSerializerandMissingParserturned 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.jsonS.json(struct)->S.jsonString(struct)S.parseJsonWith->S.parseJsonStringWithS.serializeToJsonWith->S.serializeToJsonStringWithS.asyncRecursive->S.recursive. TheS.recursivenow works for both sync and async structs- Added
S.toUnknownhelper to cast the struct type fromS.t<'any>toS.t<unknown> - Bug fix:
S.jsonStringthrows an error if you pass a non-JSONable struct. It used to silently serialize toundefinedinstead of the expectedstringtype - Bug fix: Errors happening during the operation compilation phase now have a correct path
- Added
S.Path.dynamic S.deprecatedoesn't make a struct optional anymore. You need to manually wrap it withS.optionS.defaultis renamed toS.Option.getOrWith. Also, now you can useS.Option.getOr- Improved
S.namelogic to print more beautiful names for built-in structs. Name is used for errors, codegen, and external tools - Added
S.setNameto be able to customize the struct name - The same as effect structs, the
S.customnow accepts only one argument, which is a function that getseffectCtxand returns a record with parser and serializer. Also, the name argument is not labeled anymore - The
S.preprocessstopped failing with theInvalidOperationerror when parser or serializer missing - Removed
S.failandS.advancedFailin favor of havingeffectCtxwithfailandfailWithErrormethods S.variantused to fail when using value multiple times. Now it allows to create a struct and fails only on serializing withInvalidOperationcode- Added
failandfailWithErrormethods to thecatchCtx Object.UnknownKeysmoved from metadata totaggedtype- Removed the need to pass
()as an ending argument to built-in refinement functions - Moved all function optional arguments to the end
Other TS API changes
- Updated
S.Structtype to include both input and output types - You can get the struct input type by using
S.Input<struct>and output type by usingS.Output<struct>(previouseS.Infer) - The
serializeandserializeOrThrowstarted returning the structInputtype instead ofunknown - Changed primitive structs from functions to values. For example,
S.string()->S.string - Added support for
SymbolandBigIntliterals - Renamed
S.json(struct)toS.jsonString(struct) - Added
Jsontype and theS.jsonstruct for it S.literal(null)now returnsS.Struct<null>instead ofS.Struct<undefined>- Removed
S.nan. UseS.literal(NaN)instead - Removed
defaultmethod. You can pass the default value to the second argument of theS.optionalfunction - The
refinemethod now accepts only one refining function which is applied both for parser and serializer. If you want to refine the parser and serializer separately as before, useS.transforminstead - Removed
S.failin favor of having actxwithfailmethod - The
asyncRefineis renamed toasyncParserRefine S.objecttype check started usinginput.constructor===Objectinstead oftypeof input === "object". UseS.customif it doesn't work for you- Empty
S.tuplenow returns empty array during parsing instead ofundefined S.tuplewith single item doesn't unwrap it from array during parsing- Removed
ObjectStructtype - Added built-in refinements and transforms
StructErrorrenamed toErrorwhich now contains themessagegetter and other fields.
Semi-automated migration
π§ The migration file is WIP. I'll update it while migrating projects to V5.
The release contains a lot of clean up with API breaking change, so I've prepared a script you can run with comby.dev that will do parts of the migration for you automatically.
-
Create
migration.tomlin your project root -
Copy the following content to the
migration.toml:
[string]
match="S.string()"
rewrite="S.string"
[string-url]
match="S.String.url()"
rewrite="S.String.url"
[float]
match="S.float()"
rewrite="S.float"
[bool]
match="S.bool()"
rewrite="S.bool"
[int]
match="S.int()"
rewrite="S.int"
[unit]
match="S.unit()"
rewrite="S.unit"
[unknown]
match="S.unknown()"
rewrite="S.unknown"
[never]
match="S.never()"
rewrite="S.never"
[literal-string]
match="S.literal(String(:[literal]))"
rewrite="S.literal(:[literal])"
[fail]
match="S.fail"
rewrite="s.fail"
[object-ctx]
match="S.object(o :[x])"
rewrite="S.object(s :[x])"
[object-ctx-type]
match="S.Object.definerCtx"
rewrite="S.Object.ctx"
[field]
match="o->S.field"
rewrite="s.field"
[field-custom]
match=" :[ctx]->S.field"
rewrite=" :[ctx].field"
[tag]
match="s.field(:[name], S.literal(:[literal]))->ignore"
rewrite="s.tag(:[name], :[literal])"
[error-message]
match="S.Error.toString"
rewrite="S.Error.message"
[error-t]
match="S.Error.t"
rewrite="S.error"
[result-map-error-to-string]
match="S.Result.mapErrorToString"
rewrite="Result.mapError(S.Error.toString)"
[result-getexn-with-parse]
match="S.parseWith(:[struct])->S.Result.getExn"
rewrite="S.parseOrRaiseWith(:[struct])"
[result-getexn-with-parse-any]
match="S.parseAnyWith(:[struct])->S.Result.getExn"
rewrite="S.parseAnyOrRaiseWith(:[struct])"
[result-getexn-with-parse-any-2]
match="S.parseAnyWith(:[struct]) ->S.Result.getExn"
rewrite="S.parseAnyOrRaiseWith(:[struct])"
[result-getexn-with-serialize]
match="S.serializeWith(:[struct])->S.Result.getExn"
rewrite="S.serializeOrRaiseWith(:[struct])"
[serialize-to-json-with]
match="S.serializeToJsonWith"
rewrite="S.serializeToJsonStringWith"
[parse-json-with]
match="S.parseJsonWith"
rewrite="S.parseJsonStringWith"
[default]
match="S.default"
rewrite="S.Option.getOrWith"
[refine-parser]
match="S.refine(~parser=:[x], ())"
rewrite="S.refine(s => :[x])"
[refine-parser-2]
match="S.refine( ~parser=:[x], (), )"
rewrite="S.refine(s => :[x])"
[refine-serializer]
match="S.refine( ~serializer=:[x], (), )"
rewrite="S.refine(s => :[x])"
[refine-serializer-2]
match="S.refine(~serializer=:[x], ())"
rewrite="S.refine(s => :[x])"
[transform-1-parser]
match="S.transform(~parser, ())"
rewrite="S.transform(s => {parser: parser})"
[transform-1-serializer]
match="S.transform(~serializer, ())"
rewrite="S.transform(s => {serializer: serializer})"
[transform-1-parser-serializer]
match="S.transform(~parser, ~serializer, ())"
rewrite="S.transform(s => {parser, serializer})"
[transform-1-serializer-parser]
match="S.transform(~serializer, ~parser, ())"
rewrite="S.transform(s => {parser, serializer})"
[transform-2]
match="S.transform(~parser=:[parser], ~asyncParser=:[asyncParserArg] => :[asyncParserBody], ())"
rewrite="S.transform(s => {parser: :[parser], asyncParser: :[asyncParserArg] => () => :[asyncParserBody]})"
[transform-3]
match="S.transform(~parser=:[parser], ~serializer=:[serializer], ())"
rewrite="S.transform(s => {parser: :[parser], serializer: :[serializer]})"
[transform-3-multiline]
match="S.transform( ~parser=:[parser], ~serializer=:[serializer], (), )"
rewrite="S.transform(s => {parser: :[parser], serializer: :[serializer]})"
[transform-4-parser-only]
match="S.transform(~parser=:[parser], ())"
rewrite="S.transform(s => {parser: :[parser]})"
[transform-4-serializer-only]
match="S.transform(~serializer=:[serializer], ())"
rewrite="S.transform(s => {serializer: :[serializer]})"
[transform-4-async-parser-only]
match="S.transform(~asyncParser=:[asyncParserArg] => :[asyncParserBody], ())"
rewrite="S.transform(s => {asyncParser: :[asyncParserArg] => () => :[asyncParserBody]})"
[transform-4-async-parser-only-multiline]
match="S.transform( ~asyncParser=:[asyncParserArg] => :[asyncParserBody], (), )"
rewrite="S.transform(s => {asyncParser: :[asyncParserArg] => () => :[asyncParserBody]})"- Run the script in your project root. Assumes
migration.tomlhas been copied in place to your project root.
comby -config migration.toml -f .res -matcher .re -exclude-dir node_modules,__generated__ -iThe migration script is a set of instructions that Comby runs in sequence. You're encouraged to take migration.toml and tweak it so it fits your needs. Comby is powerful. It can do interactive rewriting and numerous other useful stuff. Check it out, but please note it's not intended to cover all of the migration necessary. You'll still likely need to do a few manual fixes after running the migration scripts.