Skip to content

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 constraints
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.Int.pipe(Schema.between(1, 80)),
})
// Create an Arbitrary based on the schema
const arb = Arbitrary.make(Person)
// Generate random samples from the Arbitrary
console.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 transformation
const schema1 = Schema.compose(Schema.NonEmptyString, Schema.Trim).pipe(Schema.maxLength(500))
// May produce empty strings due to ignored NonEmpty filter
console.log(FastCheck.sample(Arbitrary.make(schema1), 2))
/*
Example Output:
[ '', '"Ry' ]
*/
// Schema with filters applied after transformations
const schema2 = Schema.Trim.pipe(Schema.nonEmptyString(), Schema.maxLength(500))
// Adheres to all filters, avoiding empty strings
console.log(FastCheck.sample(Arbitrary.make(schema2), 2))
/*
Example Output:
[ ']H+MPXgZKz', 'SNS|waP~\\' ]
*/

Explanation:

  • schema1: 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.
  • 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:

  1. Apply Filters First: Define filters for the initial type (I).
  2. Apply Transformations: Add transformations to convert the data.
  3. 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 filters
const 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 filters
const 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 }
]
*/