Schema to Arbitrary
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 (Generating Arbitrary Data for a Schema)
import { Arbitrary, FastCheck, Schema } from "effect"
// Define a Person schema with constraintsconst Person = Schema.Struct({ name: Schema.NonEmptyString, age: Schema.Int.pipe(Schema.between(1, 80)),})
// Create an Arbitrary based on the schemaconst arb = Arbitrary.make(Person)
// Generate random samples from the Arbitraryconsole.log(FastCheck.sample(arb, 2))/*Example Output:[ { name: 'q r', age: 3 }, { name: '&|', age: 6 } ]*/To make the output more realistic, see the Customizing Arbitrary Data Generation section.
Filters
When generating random values, Arbitrary tries to follow the schema’s constraints. It uses the most appropriate fast-check primitives and applies constraints if the primitive supports them.
For instance, if you define an age property as:
Schema.Int.pipe(Schema.between(1, 80))the arbitrary generation will use:
FastCheck.integer({ min: 1, max: 80 })to produce values within that range.
Patterns
To generate efficient arbitraries for strings that must match a certain pattern, use the Schema.pattern filter instead of writing a custom filter:
Example (Using Schema.pattern for Pattern Constraints)
import { Schema } from "effect"
// ❌ Without using Schema.pattern (less efficient)const Bad = Schema.String.pipe(Schema.filter((s) => /^[a-z]+$/.test(s)))
// ✅ Using Schema.pattern (more efficient)const Good = Schema.String.pipe(Schema.pattern(/^[a-z]+$/))By using Schema.pattern, arbitrary generation will rely on FastCheck.stringMatching(regexp), which is more efficient and directly aligned with the defined pattern.
When multiple patterns are used, they are combined into a union. For example:
(?:${pattern1})|(?:${pattern2})This approach ensures all patterns have an equal chance of generating values when using FastCheck.stringMatching.
Transformations and Arbitrary Generation
When generating arbitrary data, it is important to understand how transformations and filters are handled within a schema:
Example (Filters and Transformations)
import { Arbitrary, FastCheck, Schema } from "effect"
// Schema with filters before the transformationconst schema1 = Schema.compose(Schema.NonEmptyString, Schema.Trim).pipe(Schema.maxLength(500))
// May produce empty strings due to ignored NonEmpty filterconsole.log(FastCheck.sample(Arbitrary.make(schema1), 2))/*Example Output:[ '', '"Ry' ]*/
// Schema with filters applied after transformationsconst schema2 = Schema.Trim.pipe(Schema.nonEmptyString(), Schema.maxLength(500))
// Adheres to all filters, avoiding empty stringsconsole.log(FastCheck.sample(Arbitrary.make(schema2), 2))/*Example Output:[ ']H+MPXgZKz', 'SNS|waP~\\' ]*/Explanation:
schema1: Takes into accountSchema.maxLength(500)since it is applied after theSchema.Trimtransformation, but ignores theSchema.NonEmptyStringas it precedes the transformations.schema2: Adheres fully to all filters because they are correctly sequenced after transformations, preventing the generation of undesired data.
Best Practices
To ensure consistent and valid arbitrary data generation, follow these guidelines:
- Apply Filters First: Define filters for the initial type (
I). - Apply Transformations: Add transformations to convert the data.
- Apply Final Filters: Use filters for the transformed type (
A).
This setup ensures that each stage of data processing is precise and well-defined.
Example (Avoid Mixed Filters and Transformations)
Avoid haphazard combinations of transformations and filters:
import { Schema } from "effect"
// Less optimal approach: Mixing transformations and filtersconst problematic = Schema.compose(Schema.Lowercase, Schema.Trim)Prefer a structured approach by separating transformation steps from filter applications:
Example (Preferred Structured Approach)
import { Schema } from "effect"
// Recommended: Separate transformations and filtersconst improved = 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 customize how arbitrary data is generated using the arbitrary annotation in schema definitions.
Example (Custom Arbitrary Generator)
import { Arbitrary, FastCheck, Schema } from "effect"
const Name = Schema.NonEmptyString.annotations({ arbitrary: () => (fc) => fc.constantFrom("Alice Johnson", "Dante Howell", "Marta Reyes"),})
const Age = Schema.Int.pipe(Schema.between(1, 80))
const Person = Schema.Struct({ name: Name, age: Age,})
const arb = Arbitrary.make(Person)
console.log(FastCheck.sample(arb, 2))/*Example Output:[ { name: 'Dante Howell', age: 6 }, { name: 'Marta Reyes', age: 53 } ]*/The annotation allows access 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.
Integration with Fake Data Generators
When using mocking libraries like @faker-js/faker,
you can combine them with fast-check to generate realistic data for testing purposes.
Example (Integrating with Faker)
import { Arbitrary, FastCheck, Schema } from "effect"import { faker } from "@faker-js/faker"
const Name = Schema.NonEmptyString.annotations({ arbitrary: () => (fc) => fc.constant(null).map(() => { // Each time the arbitrary is sampled, faker generates a new name return faker.person.fullName() }),})
const Age = Schema.Int.pipe(Schema.between(1, 80))
const Person = Schema.Struct({ name: Name, age: Age,})
const arb = Arbitrary.make(Person)
console.log(FastCheck.sample(arb, 2))/*Example Output:[ { name: 'Henry Dietrich', age: 68 }, { name: 'Lucas Haag', age: 52 }]*/