diff --git a/.changeset/mean-cycles-impress.md b/.changeset/mean-cycles-impress.md new file mode 100644 index 0000000000..6a17d9527e --- /dev/null +++ b/.changeset/mean-cycles-impress.md @@ -0,0 +1,5 @@ +--- +"@effect/schema": patch +--- + +add `requiredToOptional` function to `Schema` module, closes #2881 diff --git a/packages/schema/README.md b/packages/schema/README.md index b8675771b2..e1cdd004be 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -2882,15 +2882,15 @@ console.log(Product.make({ name: "Laptop", price: 999, quantity: 2 })) // { name ### Optional Fields Primitives -The `optional` API is based on two primitives: `pptionalToOptional` and `optionalToRequired`. These primitives are incredibly useful for defining property signatures with more precision. +The `optional` API is based on two primitives: `optionalToOptional` and `optionalToRequired`. These primitives are incredibly useful for defining property signatures with more precision. #### optionalToOptional -The `pptionalToOptional` API is used to manage the transformation from an optional field to another optional field. With this, we can control both the output type and the presence or absence of the field. +The `optionalToOptional` API is used to manage the transformation from an optional field to another optional field. With this, we can control both the output type and the presence or absence of the field. For example a common use case is to equate a specific value in the source field with the absence of value in the destination field. -Here's the signature of the `pptionalToOptional` API: +Here's the signature of the `optionalToOptional` API: ```ts export const optionalToOptional = ( @@ -3000,6 +3000,51 @@ const encode = Schema.encodeSync(schema) console.log(encode({ a: "foo" })) // Output: { a: 'foo' } ``` +#### requiredToOptional + +This API allows developers to specify how a field that is normally required can be treated as optional based on custom logic. + +```ts +export const requiredToOptional = ( + from: Schema, + to: Schema, + options: { + readonly decode: (fa: FA) => Option.Option + readonly encode: (o: Option.Option) => FA + } +): PropertySignature<"?:", TA, never, ":", FI, false, FR | TR> +``` + +- **`from` and `to` Schemas**: Define the starting and ending schemas for the transformation. +- **`decode`**: Custom logic for transforming the required input into an optional output. +- **`encode`**: Defines how to handle the potentially optional input when encoding it back to a required output. + +**Example** + +Let's look at a practical example where a field `name` that is typically required can be considered optional if it's an empty string during decoding, and ensure there is always a value during encoding by providing a default. + +```ts +import { Schema } from "@effect/schema" +import { Option } from "effect" + +const schema = Schema.Struct({ + name: Schema.requiredToOptional(Schema.String, Schema.String, { + decode: Option.liftPredicate((s) => s !== ""), // empty string is considered as absent + encode: Option.getOrElse(() => "") + }) +}) + +const decode = Schema.decodeUnknownSync(schema) + +console.log(decode({ name: "John" })) // Output: { name: 'John' } +console.log(decode({ name: "" })) // Output: {} + +const encode = Schema.encodeSync(schema) + +console.log(encode({ name: "John" })) // { name: 'John' } +console.log(encode({})) // Output: { name: '' } +``` + ### Renaming Properties ```ts diff --git a/packages/schema/dtslint/Context.ts b/packages/schema/dtslint/Context.ts index 35da9f249a..4e704d80ea 100644 --- a/packages/schema/dtslint/Context.ts +++ b/packages/schema/dtslint/Context.ts @@ -167,6 +167,13 @@ S.NonEmptyArray(aContext) // $ExpectType PropertySignature<":", string, never, ":", string, false, "aContext"> S.propertySignature(aContext) +// --------------------------------------------- +// optionalToOptional +// --------------------------------------------- + +// $ExpectType PropertySignature<"?:", string, never, "?:", string, false, "aContext"> +S.optionalToOptional(aContext, S.String, { decode: (o) => o, encode: (o) => o }) + // --------------------------------------------- // optionalToRequired // --------------------------------------------- @@ -174,6 +181,13 @@ S.propertySignature(aContext) // $ExpectType PropertySignature<":", string, never, "?:", string, false, "aContext"> S.optionalToRequired(aContext, S.String, { decode: Option.getOrElse(() => ""), encode: Option.some }) +// --------------------------------------------- +// requiredToOptional +// --------------------------------------------- + +// $ExpectType PropertySignature<"?:", string, never, ":", string, false, "aContext"> +S.requiredToOptional(aContext, S.String, { decode: Option.some, encode: Option.getOrElse(() => "") }) + // --------------------------------------------- // optional // --------------------------------------------- diff --git a/packages/schema/dtslint/Schema.ts b/packages/schema/dtslint/Schema.ts index 2b3f66957e..33bfaec6e3 100644 --- a/packages/schema/dtslint/Schema.ts +++ b/packages/schema/dtslint/Schema.ts @@ -2454,3 +2454,37 @@ S.asSchema(MyTaggedStruct) // $ExpectType [props: { readonly _tag?: "Product"; readonly name: string; readonly category?: "Electronics"; readonly price: number; }] hole>() + +// --------------------------------------------- +// optionalToOptional +// --------------------------------------------- + +// $ExpectType Schema<{ readonly a?: string; }, { readonly a?: string; }, "a"> +S.asSchema(S.Struct({ a: S.optionalToOptional(aContext, S.String, { decode: (o) => o, encode: (o) => o }) })) + +// $ExpectType Struct<{ a: PropertySignature<"?:", string, never, "?:", string, false, "a">; }> +S.Struct({ a: S.optionalToOptional(aContext, S.String, { decode: (o) => o, encode: (o) => o }) }) + +// --------------------------------------------- +// optionalToRequired +// --------------------------------------------- + +// $ExpectType Schema<{ readonly a: string; }, { readonly a?: string; }, "a"> +S.asSchema( + S.Struct({ a: S.optionalToRequired(aContext, S.String, { decode: Option.getOrElse(() => ""), encode: Option.some }) }) +) + +// $ExpectType Struct<{ a: PropertySignature<":", string, never, "?:", string, false, "a">; }> +S.Struct({ a: S.optionalToRequired(aContext, S.String, { decode: Option.getOrElse(() => ""), encode: Option.some }) }) + +// --------------------------------------------- +// requiredToOptional +// --------------------------------------------- + +// $ExpectType Schema<{ readonly a?: string; }, { readonly a: string; }, "a"> +S.asSchema( + S.Struct({ a: S.requiredToOptional(aContext, S.String, { decode: Option.some, encode: Option.getOrElse(() => "") }) }) +) + +// $ExpectType Struct<{ a: PropertySignature<"?:", string, never, ":", string, false, "a">; }> +S.Struct({ a: S.requiredToOptional(aContext, S.String, { decode: Option.some, encode: Option.getOrElse(() => "") }) }) diff --git a/packages/schema/src/Schema.ts b/packages/schema/src/Schema.ts index af83db2ffc..c87793048b 100644 --- a/packages/schema/src/Schema.ts +++ b/packages/schema/src/Schema.ts @@ -1812,7 +1812,7 @@ export const fromKey: { }) /** - * Converts an optional property to a required one through a transformation `Option -> Option`. + * Converts an optional property to a required one through a transformation `Option -> Type`. * * - `decode`: `none` as argument means the value is missing in the input. * - `encode`: `none` as return value means the value will be missing in the output. @@ -1837,6 +1837,32 @@ export const optionalToRequired = ( ) ) +/** + * Converts an optional property to a required one through a transformation `Type -> Option`. + * + * - `decode`: `none` as return value means the value will be missing in the output. + * - `encode`: `none` as argument means the value is missing in the input. + * + * @category PropertySignature + * @since 1.0.0 + */ +export const requiredToOptional = ( + from: Schema, + to: Schema, + options: { + readonly decode: (fa: FA) => option_.Option + readonly encode: (o: option_.Option) => FA + } +): PropertySignature<"?:", TA, never, ":", FI, false, FR | TR> => + makePropertySignature( + new PropertySignatureTransformation( + new FromPropertySignature(from.ast, false, true, {}, undefined), + new ToPropertySignature(to.ast, true, true, {}, undefined), + option_.flatMap(options.decode), + (o) => option_.some(options.encode(o)) + ) + ) + /** * Converts an optional property to another optional property through a transformation `Option -> Option`. * diff --git a/packages/schema/test/Schema/optionalToRequired.test.ts b/packages/schema/test/Schema/optionalToRequired.test.ts index 38a0cf9659..300eb60e56 100644 --- a/packages/schema/test/Schema/optionalToRequired.test.ts +++ b/packages/schema/test/Schema/optionalToRequired.test.ts @@ -8,9 +8,13 @@ describe("optionalToRequired", () => { const ps = S.optionalToRequired( S.NumberFromString, S.BigIntFromNumber, - { decode: Option.getOrElse(() => 0), encode: Option.some } + { decode: Option.getOrElse(() => 0), encode: Option.liftPredicate((n) => n !== 0) } ) const schema = S.Struct({ a: ps }) + await Util.expectDecodeUnknownSuccess(schema, {}, { a: 0n }) await Util.expectDecodeUnknownSuccess(schema, { a: "1" }, { a: 1n }) + + await Util.expectEncodeSuccess(schema, { a: 0n }, {}) + await Util.expectEncodeSuccess(schema, { a: 1n }, { a: "1" }) }) }) diff --git a/packages/schema/test/Schema/requiredToOptional.test.ts b/packages/schema/test/Schema/requiredToOptional.test.ts new file mode 100644 index 0000000000..4abdfb336a --- /dev/null +++ b/packages/schema/test/Schema/requiredToOptional.test.ts @@ -0,0 +1,20 @@ +import * as S from "@effect/schema/Schema" +import * as Util from "@effect/schema/test/TestUtils" +import * as Option from "effect/Option" +import { describe, it } from "vitest" + +describe("requiredToOptional", () => { + it("two transformation schemas", async () => { + const ps = S.requiredToOptional( + S.NumberFromString, + S.BigIntFromNumber, + { decode: Option.liftPredicate((n) => n !== 0), encode: Option.getOrElse(() => 0) } + ) + const schema = S.Struct({ a: ps }) + await Util.expectDecodeUnknownSuccess(schema, { a: "0" }, {}) + await Util.expectDecodeUnknownSuccess(schema, { a: "1" }, { a: 1n }) + + await Util.expectEncodeSuccess(schema, {}, { a: "0" }) + await Util.expectEncodeSuccess(schema, { a: 1n }, { a: "1" }) + }) +})