diff --git a/.changeset/beige-turkeys-hunt.md b/.changeset/beige-turkeys-hunt.md new file mode 100644 index 0000000000..08bb94b94f --- /dev/null +++ b/.changeset/beige-turkeys-hunt.md @@ -0,0 +1,33 @@ +--- +"@effect/schema": patch +--- + +Add `filterEffect` API, closes #3165 + +The `filterEffect` function enhances the `filter` functionality by allowing the integration of effects, thus enabling asynchronous or dynamic validation scenarios. This is particularly useful when validations need to perform operations that require side effects, such as network requests or database queries. + +**Example: Validating Usernames Asynchronously** + +```ts +import { Schema } from "@effect/schema" +import { Effect } from "effect" + +async function validateUsername(username: string) { + return Promise.resolve(username === "gcanti") +} + +const ValidUsername = Schema.String.pipe( + Schema.filterEffect((username) => + Effect.promise(() => + validateUsername(username).then((valid) => valid || "Invalid username") + ) + ) +).annotations({ identifier: "ValidUsername" }) + +Effect.runPromise(Schema.decodeUnknown(ValidUsername)("xxx")).then(console.log) +/* +ParseError: ValidUsername +└─ Transformation process failure + └─ Invalid username +*/ +``` diff --git a/packages/schema/README.md b/packages/schema/README.md index a4c19f5789..29767a463c 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -2026,6 +2026,8 @@ const mySymbolSchema = Schema.UniqueSymbolFromSelf(mySymbol) Using the `Schema.filter` function, developers can define custom validation logic that goes beyond basic type checks, allowing for in-depth control over the data conformity process. This function applies a predicate to data, and if the data fails the predicate's condition, a custom error message can be returned. +**Note**. For effectful filters, see `filterEffect`. + **Simple Validation Example**: ```ts @@ -4810,6 +4812,36 @@ Output: */ ``` +## Effectful Filters + +The `filterEffect` function enhances the `filter` functionality by allowing the integration of effects, thus enabling asynchronous or dynamic validation scenarios. This is particularly useful when validations need to perform operations that require side effects, such as network requests or database queries. + +**Example: Validating Usernames Asynchronously** + +```ts +import { Schema } from "@effect/schema" +import { Effect } from "effect" + +async function validateUsername(username: string) { + return Promise.resolve(username === "gcanti") +} + +const ValidUsername = Schema.String.pipe( + Schema.filterEffect((username) => + Effect.promise(() => + validateUsername(username).then((valid) => valid || "Invalid username") + ) + ) +).annotations({ identifier: "ValidUsername" }) + +Effect.runPromise(Schema.decodeUnknown(ValidUsername)("xxx")).then(console.log) +/* +ParseError: ValidUsername +└─ Transformation process failure + └─ Invalid username +*/ +``` + ## String Transformations ### split diff --git a/packages/schema/dtslint/Schema.ts b/packages/schema/dtslint/Schema.ts index 2d7ae6734e..1a35cf19ee 100644 --- a/packages/schema/dtslint/Schema.ts +++ b/packages/schema/dtslint/Schema.ts @@ -1,11 +1,8 @@ import type * as AST from "@effect/schema/AST" import * as ParseResult from "@effect/schema/ParseResult" import * as S from "@effect/schema/Schema" -import * as Brand from "effect/Brand" +import { Brand, Context, Effect, Number as N, Option, String as Str } from "effect" import { hole, identity, pipe } from "effect/Function" -import * as N from "effect/Number" -import * as Option from "effect/Option" -import * as Str from "effect/String" import type { Simplify } from "effect/Types" declare const anyNever: S.Schema @@ -19,6 +16,8 @@ declare const cContext: S.Schema class A extends S.Class("A")({ a: S.NonEmpty }) {} +const ServiceA = Context.GenericTag<"ServiceA", string>("ServiceA") + // --------------------------------------------- // SchemaClass // --------------------------------------------- @@ -1281,6 +1280,35 @@ pipe( ) ) +// --------------------------------------------- +// filterEffect +// --------------------------------------------- + +// $ExpectType filterEffect +S.String.pipe(S.filterEffect(( + _s // $ExpectType string +) => Effect.succeed(undefined))) + +// $ExpectType filterEffect +S.String.pipe(S.filterEffect((s) => + Effect.gen(function*() { + const str = yield* ServiceA + return str === s + }) +)) + +// $ExpectType filterEffect +S.filterEffect(S.String, ( + _s // $ExpectType string +) => Effect.succeed(undefined)) + +// $ExpectType filterEffect +S.filterEffect(S.String, (s) => + Effect.gen(function*() { + const str = yield* ServiceA + return str === s + })) + // --------------------------------------------- // compose // --------------------------------------------- diff --git a/packages/schema/src/Schema.ts b/packages/schema/src/Schema.ts index b2a7773742..1d75fad5b9 100644 --- a/packages/schema/src/Schema.ts +++ b/packages/schema/src/Schema.ts @@ -3196,7 +3196,7 @@ export interface filter extends refine => { if (Predicate.isBoolean(item)) { @@ -3221,7 +3221,7 @@ const fromFilterPredicateReturnTypeItem = ( const toFilterParseIssue = ( out: FilterReturnType, - ast: AST.Refinement, + ast: AST.Refinement | AST.Transformation, input: unknown ): option_.Option => { if (util_.isSingle(out)) { @@ -3294,6 +3294,60 @@ export function filter( } } +/** + * @category api interface + * @since 0.68.17 + */ +export interface filterEffect + extends transformOrFail>, FD> +{} + +/** + * @category transformations + * @since 0.68.17 + */ +export const filterEffect: { + ( + f: ( + a: Types.NoInfer>, + options: ParseOptions, + self: AST.Transformation + ) => Effect.Effect + ): (self: S) => filterEffect + ( + self: S, + f: ( + a: Types.NoInfer>, + options: ParseOptions, + self: AST.Transformation + ) => Effect.Effect + ): filterEffect +} = dual(2, ( + self: S, + f: ( + a: Types.NoInfer>, + options: ParseOptions, + self: AST.Transformation + ) => Effect.Effect +): filterEffect => + transformOrFail( + self, + typeSchema(self), + { + strict: true, + decode: (a, options, ast) => + ParseResult.flatMap( + f(a, options, ast), + (filterReturnType) => + option_.match(toFilterParseIssue(filterReturnType, ast, a), { + onNone: () => ParseResult.succeed(a), + onSome: ParseResult.fail + }) + ), + encode: ParseResult.succeed + } + )) + /** * @category api interface * @since 0.67.0 @@ -3335,7 +3389,7 @@ const makeTransformationClass = exten * Create a new `Schema` by transforming the input and output of an existing `Schema` * using the provided mapping functions. * - * @category combinators + * @category transformations * @since 0.67.0 */ export const transform: { diff --git a/packages/schema/test/Schema/filterEffect.test.ts b/packages/schema/test/Schema/filterEffect.test.ts new file mode 100644 index 0000000000..cd8b961164 --- /dev/null +++ b/packages/schema/test/Schema/filterEffect.test.ts @@ -0,0 +1,197 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import * as S from "@effect/schema/Schema" +import * as Util from "@effect/schema/test/TestUtils" +import * as Effect from "effect/Effect" +import { describe, expect, it } from "vitest" + +describe("filterEffect", () => { + it("shoudl expose the original schema as `from`", async () => { + const schema = S.filterEffect(S.String, () => Effect.succeed(true)) + expect(schema.from).toBe(S.String) + expect(schema.to.ast).toBe(S.String.ast) + }) + + describe("ParseIssue overloading", () => { + it("return a Type", async () => { + const schema = S.filterEffect(S.Struct({ a: S.String, b: S.String }), (o) => { + if (o.b !== o.a) { + return Effect.succeed( + new ParseResult.Type(S.Literal(o.a).ast, o.b, `b should be equal to a's value ("${o.a}")`) + ) + } + return Effect.succeed(true) + }) + + await Util.expectDecodeUnknownSuccess(schema, { a: "x", b: "x" }) + await Util.expectDecodeUnknownFailure( + schema, + { a: "a", b: "b" }, + `({ readonly a: string; readonly b: string } <-> { readonly a: string; readonly b: string }) +└─ Transformation process failure + └─ b should be equal to a's value ("a")` + ) + }) + + const ValidString = S.Trim.pipe(S.minLength(1, { message: () => "ERROR_MIN_LENGTH" })) + const Test = S.Struct({ + a: S.Struct({ + b: S.String, + c: ValidString + }), + d: S.Tuple(S.String, ValidString) + }).annotations({ identifier: "Test" }) + + it("return a Pointer", async () => { + const schema = Test.pipe(S.filterEffect((input) => { + if (input.a.b !== input.a.c) { + return Effect.succeed( + new ParseResult.Pointer( + ["a", "c"], + input, + new ParseResult.Type(S.Literal(input.a.b).ast, input.a.c) + ) + ) + } + if (input.d[0] !== input.d[1]) { + return Effect.succeed( + new ParseResult.Pointer( + ["d", 1], + input, + new ParseResult.Type(S.Literal(input.d[0]).ast, input.d[1]) + ) + ) + } + return Effect.succeed(true) + })) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: " " }, d: ["-", "-"] }, + `(Test <-> Test) +└─ Encoded side transformation failure + └─ Test + └─ ["a"] + └─ { readonly b: string; readonly c: a string at least 1 character(s) long } + └─ ["c"] + └─ ERROR_MIN_LENGTH` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: "c" }, d: ["-", "-"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ ["a"]["c"] + └─ Expected "b", actual "c"` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "-", c: "-" }, d: ["item0", "item1"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ ["d"][1] + └─ Expected "item0", actual "item1"` + ) + }) + + it("return a path and a message", async () => { + const schema = Test.pipe(S.filterEffect((input) => { + if (input.a.b !== input.a.c) { + return Effect.succeed({ + path: ["a", "c"], + message: "FILTER1" + }) + } + if (input.d[0] !== input.d[1]) { + return Effect.succeed({ + path: ["d", 1], + message: "FILTER2" + }) + } + return Effect.succeed(true) + })) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: " " }, d: ["-", "-"] }, + `(Test <-> Test) +└─ Encoded side transformation failure + └─ Test + └─ ["a"] + └─ { readonly b: string; readonly c: a string at least 1 character(s) long } + └─ ["c"] + └─ ERROR_MIN_LENGTH` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: "c" }, d: ["-", "-"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ ["a"]["c"] + └─ FILTER1` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "-", c: "-" }, d: ["item0", "item1"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ ["d"][1] + └─ FILTER2` + ) + }) + + it("return many paths and messages", async () => { + const schema = Test.pipe(S.filterEffect((input) => { + const issues: Array = [] + if (input.a.b !== input.a.c) { + issues.push({ + path: ["a", "c"], + message: "FILTER1" + }) + } + if (input.d[0] !== input.d[1]) { + issues.push({ + path: ["d", 1], + message: "FILTER2" + }) + } + return Effect.succeed(issues) + })) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: " " }, d: ["-", "-"] }, + `(Test <-> Test) +└─ Encoded side transformation failure + └─ Test + └─ ["a"] + └─ { readonly b: string; readonly c: a string at least 1 character(s) long } + └─ ["c"] + └─ ERROR_MIN_LENGTH` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: "c" }, d: ["-", "-"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ ["a"]["c"] + └─ FILTER1` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "-", c: "-" }, d: ["item0", "item1"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ ["d"][1] + └─ FILTER2` + ) + await Util.expectDecodeUnknownFailure( + schema, + { a: { b: "b", c: "c" }, d: ["item0", "item1"] }, + `(Test <-> Test) +└─ Transformation process failure + └─ (Test <-> Test) + ├─ ["a"]["c"] + │ └─ FILTER1 + └─ ["d"][1] + └─ FILTER2` + ) + }) + }) +})