Arbitrary

On this page

Generating Arbitraries

The Arbitrary.make function allows for the creation of random values that align with a specific Schema<A, I, R>. This function returns an Arbitrary<A> from the fast-check library, which is particularly useful for generating random test data that adheres to the defined schema constraints.

Example

ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.NumberFromString.pipe(Schema.int(), Schema.between(0, 200))
})
 
// This will generate an Arbitrary for the Person schema.
const PersonArbitraryType = Arbitrary.make(Person)
 
console.log(FastCheck.sample(PersonArbitraryType, 2))
/*
Example Output:
[ { name: 'q r', age: 1 }, { name: '&|', age: 133 } ]
*/
ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
 
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.NumberFromString.pipe(Schema.int(), Schema.between(0, 200))
})
 
// This will generate an Arbitrary for the Person schema.
const PersonArbitraryType = Arbitrary.make(Person)
 
console.log(FastCheck.sample(PersonArbitraryType, 2))
/*
Example Output:
[ { name: 'q r', age: 1 }, { name: '&|', age: 133 } ]
*/

The entirety of fast-check's API is accessible via the FastCheck export, allowing direct use of all its functionalities within your projects.

Transformations and Arbitrary Generation

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

Filters applied before the last transformation in the transformation chain are not considered during the generation of arbitrary data.

Illustrative Example

ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
 
const schema1 = Schema.compose(Schema.NonEmptyString, Schema.Trim).pipe(
Schema.maxLength(500)
)
 
// This might produce empty strings, despite the `NonEmpty` filter, due to the sequence of filters.
console.log(FastCheck.sample(Arbitrary.make(schema1), 2))
/*
Example Output:
[ '', '"Ry' ]
*/
 
const schema2 = Schema.Trim.pipe(
Schema.nonEmptyString(),
Schema.maxLength(500)
)
 
// This configuration ensures no empty strings are produced, adhering to the `nonEmpty()` filter properly.
console.log(FastCheck.sample(Arbitrary.make(schema2), 2))
/*
Example Output:
[ ']H+MPXgZKz', 'SNS|waP~\\' ]
*/
ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
 
const schema1 = Schema.compose(Schema.NonEmptyString, Schema.Trim).pipe(
Schema.maxLength(500)
)
 
// This might produce empty strings, despite the `NonEmpty` filter, due to the sequence of filters.
console.log(FastCheck.sample(Arbitrary.make(schema1), 2))
/*
Example Output:
[ '', '"Ry' ]
*/
 
const schema2 = Schema.Trim.pipe(
Schema.nonEmptyString(),
Schema.maxLength(500)
)
 
// This configuration ensures no empty strings are produced, adhering to the `nonEmpty()` filter properly.
console.log(FastCheck.sample(Arbitrary.make(schema2), 2))
/*
Example Output:
[ ']H+MPXgZKz', 'SNS|waP~\\' ]
*/

Explanation:

  • Schema 1: Takes into account Schema.maxLength(500) since it is applied after the Schema.Trim transformation, but ignores the Schema.NonEmptyString as it precedes the transformations.
  • Schema 2: Adheres fully to all filters because they are correctly sequenced after transformations, preventing the generation of undesired data.

Best Practices

For effective and clear data generation, it's advisable to organize transformations and filters methodically. A suggested pattern is:

  1. Filters for the initial type (I).
  2. Followed by transformations.
  3. And then filters for the transformed type (A).

This setup ensures that each stage of data processing is precise and well-defined.

Illustrative Example

Avoid haphazard combinations of transformations and filters:

ts
import { Schema } from "@effect/schema"
 
// Example of less optimal structuring where transformations and filters are mixed:
const schema = Schema.compose(Schema.Lowercase, Schema.Trim)
ts
import { Schema } from "@effect/schema"
 
// Example of less optimal structuring where transformations and filters are mixed:
const schema = Schema.compose(Schema.Lowercase, Schema.Trim)

Prefer a structured approach by 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()),
{
strict: true,
decode: (s) => s.trim().toLowerCase(),
encode: (s) => s
}
)
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()),
{
strict: true,
decode: (s) => s.trim().toLowerCase(),
encode: (s) => s
}
)

Customizing Arbitrary Data Generation

You can define how arbitrary data is generated by utilizing the arbitrary annotation in your schema definitions.

Example

ts
import { Schema } from "@effect/schema"
 
// Define a schema with a custom generator for natural numbers.
const schema = Schema.Number.annotations({
arbitrary: (/**typeParameters**/) => (fc) => fc.nat()
})
ts
import { Schema } from "@effect/schema"
 
// Define a schema with a custom generator for natural numbers.
const schema = Schema.Number.annotations({
arbitrary: (/**typeParameters**/) => (fc) => fc.nat()
})

The annotation allows access to any type parameters via the first argument (typeParameters) and the complete export of the fast-check library (fc). This setup enables you to return an Arbitrary that precisely generates the type of data desired.

Customizing a schema can disrupt previously applied filters. Filters set after the customization will remain effective, while those applied before will be disregarded.

Illustrative Example

ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
 
// 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(problematic), 2))
/*
Example Output:
[ -1600163302, -6 ]
*/
 
// 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(improved), 2))
/*
Example Output:
[ 7, 1518247613 ]
*/
ts
import { Arbitrary, FastCheck, Schema } from "@effect/schema"
 
// 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(problematic), 2))
/*
Example Output:
[ -1600163302, -6 ]
*/
 
// 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(improved), 2))
/*
Example Output:
[ 7, 1518247613 ]
*/