Skip to content

Commit

Permalink
Add filterEffect API, closes #3165 (#3166)
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jul 4, 2024
1 parent 2328e17 commit 15967cf
Show file tree
Hide file tree
Showing 5 changed files with 352 additions and 8 deletions.
33 changes: 33 additions & 0 deletions .changeset/beige-turkeys-hunt.md
Original file line number Diff line number Diff line change
@@ -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
*/
```
32 changes: 32 additions & 0 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 32 additions & 4 deletions packages/schema/dtslint/Schema.ts
Original file line number Diff line number Diff line change
@@ -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<any, never>
Expand All @@ -19,6 +16,8 @@ declare const cContext: S.Schema<boolean, boolean, "c">

class A extends S.Class<A>("A")({ a: S.NonEmpty }) {}

const ServiceA = Context.GenericTag<"ServiceA", string>("ServiceA")

// ---------------------------------------------
// SchemaClass
// ---------------------------------------------
Expand Down Expand Up @@ -1281,6 +1280,35 @@ pipe(
)
)

// ---------------------------------------------
// filterEffect
// ---------------------------------------------

// $ExpectType filterEffect<typeof String$, never>
S.String.pipe(S.filterEffect((
_s // $ExpectType string
) => Effect.succeed(undefined)))

// $ExpectType filterEffect<typeof String$, "ServiceA">
S.String.pipe(S.filterEffect((s) =>
Effect.gen(function*() {
const str = yield* ServiceA
return str === s
})
))

// $ExpectType filterEffect<typeof String$, never>
S.filterEffect(S.String, (
_s // $ExpectType string
) => Effect.succeed(undefined))

// $ExpectType filterEffect<typeof String$, "ServiceA">
S.filterEffect(S.String, (s) =>
Effect.gen(function*() {
const str = yield* ServiceA
return str === s
}))

// ---------------------------------------------
// compose
// ---------------------------------------------
Expand Down
62 changes: 58 additions & 4 deletions packages/schema/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3196,7 +3196,7 @@ export interface filter<From extends Schema.Any> extends refine<Schema.Type<From

const fromFilterPredicateReturnTypeItem = (
item: FilterOutput,
ast: AST.Refinement,
ast: AST.Refinement | AST.Transformation,
input: unknown
): option_.Option<ParseResult.ParseIssue> => {
if (Predicate.isBoolean(item)) {
Expand All @@ -3221,7 +3221,7 @@ const fromFilterPredicateReturnTypeItem = (

const toFilterParseIssue = (
out: FilterReturnType,
ast: AST.Refinement,
ast: AST.Refinement | AST.Transformation,
input: unknown
): option_.Option<ParseResult.ParseIssue> => {
if (util_.isSingle(out)) {
Expand Down Expand Up @@ -3294,6 +3294,60 @@ export function filter<A>(
}
}

/**
* @category api interface
* @since 0.68.17
*/
export interface filterEffect<S extends Schema.Any, FD = never>
extends transformOrFail<S, SchemaClass<Schema.Type<S>>, FD>
{}

/**
* @category transformations
* @since 0.68.17
*/
export const filterEffect: {
<S extends Schema.Any, FD>(
f: (
a: Types.NoInfer<Schema.Type<S>>,
options: ParseOptions,
self: AST.Transformation
) => Effect.Effect<FilterReturnType, never, FD>
): (self: S) => filterEffect<S, FD>
<S extends Schema.Any, RD>(
self: S,
f: (
a: Types.NoInfer<Schema.Type<S>>,
options: ParseOptions,
self: AST.Transformation
) => Effect.Effect<FilterReturnType, never, RD>
): filterEffect<S, RD>
} = dual(2, <S extends Schema.Any, FD>(
self: S,
f: (
a: Types.NoInfer<Schema.Type<S>>,
options: ParseOptions,
self: AST.Transformation
) => Effect.Effect<FilterReturnType, never, FD>
): filterEffect<S, FD> =>
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
Expand Down Expand Up @@ -3335,7 +3389,7 @@ const makeTransformationClass = <From extends Schema.Any, To extends Schema.Any,
* Create a new `Schema` by transforming the input and output of an existing `Schema`
* using the provided decoding functions.
*
* @category combinators
* @category transformations
* @since 0.67.0
*/
export const transformOrFail: {
Expand Down Expand Up @@ -3434,7 +3488,7 @@ export interface transform<From extends Schema.Any, To extends Schema.Any> 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: {
Expand Down
Loading

0 comments on commit 15967cf

Please sign in to comment.