Skip to content

Commit

Permalink
Enhance README with guidelines on schema transformations and arbitrar… (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jun 14, 2024
1 parent 13e3dcf commit e32b5eb
Showing 1 changed file with 76 additions and 13 deletions.
89 changes: 76 additions & 13 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,16 +1079,16 @@ Error: { readonly name: string; readonly age: number }
assertsPerson({ name: "Alice", age: 30 })
```

## Using [fast-check](https://github.com/dubzzz/fast-check) Arbitraries
## Generating Arbitraries

The `make` function provided by the `@effect/schema/Arbitrary` module represents a way of generating random values that conform to a given `Schema`. This can be useful for testing purposes, as it allows you to generate random test data that is guaranteed to be valid according to the `Schema`.
The `make` function within the `@effect/schema/Arbitrary` module allows for the creation of random values that align with a specific `Schema<A, I, R>`. This utility returns an `Arbitrary<A>` from the [fast-check](https://github.com/dubzzz/fast-check) library, which is particularly useful for generating random test data that adheres to the defined schema constraints.

```ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"

const Person = Schema.Struct({
name: Schema.String,
age: Schema.String.pipe(Schema.compose(Schema.NumberFromString), Schema.int())
name: Schema.NonEmpty,
age: Schema.NumberFromString.pipe(Schema.int(), Schema.between(0, 200))
})

/*
Expand All @@ -1101,8 +1101,8 @@ const PersonArbitraryType = Arbitrary.make(Person)

console.log(FastCheck.sample(PersonArbitraryType, 2))
/*
Output:
[ { name: 'iP=!', age: -6 }, { name: '', age: 14 } ]
Example Output:
[ { name: 'q r', age: 1 }, { name: '&|', age: 133 } ]
*/

/*
Expand All @@ -1116,11 +1116,72 @@ const PersonArbitraryEncoded = Arbitrary.make(Schema.encodedSchema(Person))

console.log(FastCheck.sample(PersonArbitraryEncoded, 2))
/*
Output:
[ { name: '{F', age: '$"{|' }, { name: 'nB}@BK', age: '^V+|W!Z' } ]
Example Output:
[ { name: 'key', age: '' }, { name: 'Gm', age: 'q' } ]
*/
```

### Understanding Schema Transformations and Arbitrary Generation

The generation of arbitrary data requires a clear understanding of how transformations and filters are applied within a schema:

- **Transformations and Filters**: Only the filters applied after the last transformation in the transformation chain are considered during arbitrary generation.

```ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"

const schema1 = Schema.compose(Schema.NonEmpty, Schema.Trim).pipe(
Schema.maxLength(500)
)
// Might output empty strings despite `NonEmpty` due to filter order.
console.log(FastCheck.sample(Arbitrary.make(schema1), 10))

const schema2 = Schema.Trim.pipe(Schema.nonEmpty(), Schema.maxLength(500))
// Ensures no empty strings, correctly applying `nonEmpty()`.
console.log(FastCheck.sample(Arbitrary.make(schema2), 10))
```

**Explanation:**

- **Schema 1**: Considers the `Schema.maxLength(500)` because it follows the `Schema.Trim` transformation but disregards `Schema.NonEmpty` as it comes before any transformations.
- **Schema 2**: Properly adheres to all applied filters by ensuring they follow transformations, thus avoiding the generation of undesired data.

**Best Practices**

Organize transformations and filters to ensure clarity and effectiveness in data generation. Follow the pattern: `(I filters) -> (transformations) -> (A filters)` where "I" and "A" stand for the initial and transformed types in the schema.

"I" and "A" represent the initial and final types in the schema, ensuring that each stage of data processing is clearly defined.

Instead of indiscriminately combining transformations and filters:

```ts
import { Schema } from "@effect/schema"

// Less optimal mixing of transformations and filters
const schema = Schema.compose(
// transformation + filter
Schema.Lowercase,
// transformation + filter
Schema.Trim
)
```

Prefer separating transformation steps from filter applications:

```ts
import { Schema } from "@effect/schema"

// Recommended approach: Separate transformations from filters
const schema = Schema.transform(
Schema.String,
Schema.String.pipe(Schema.trimmed(), Schema.lowercased()),
{
decode: (s) => s.trim().toLowerCase(),
encode: (s) => s
}
)
```

### Customizations

You can customize the output by using the `arbitrary` annotation:
Expand All @@ -1139,25 +1200,27 @@ console.log(FastCheck.sample(arb, 2))
```

> [!WARNING]
> Note that when customizing any schema, any filter **preceding** the customization will be lost, only filters **following** the customization will be respected.
> Customizing a schema can disrupt previously applied filters. Filters set after the customization will remain effective, while those applied before will be disregarded.
**Example**

```ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"

const bad = Schema.Number.pipe(Schema.positive()).annotations({
// Here, the 'positive' filter is overridden by the custom arbitrary definition
const problematic = Schema.Number.pipe(Schema.positive()).annotations({
arbitrary: () => (fc) => fc.integer()
})

console.log(FastCheck.sample(Arbitrary.make(bad), 2))
console.log(FastCheck.sample(Arbitrary.make(problematic), 2))
// Example Output: [ -1600163302, -6 ]

const good = Schema.Number.annotations({
// Here, the 'positive' filter is applied after the arbitrary customization, ensuring it is considered
const improved = Schema.Number.annotations({
arbitrary: () => (fc) => fc.integer()
}).pipe(Schema.positive())

console.log(FastCheck.sample(Arbitrary.make(good), 2))
console.log(FastCheck.sample(Arbitrary.make(improved), 2))
// Example Output: [ 7, 1518247613 ]
```

Expand Down

0 comments on commit e32b5eb

Please sign in to comment.