Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/schema-arbitrary-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Consolidate schema arbitrary derivation into `Schema.toArbitrary`, which now returns a `Schema.Arbitrary` factory that accepts the fast-check module. Remove `Schema.toArbitraryLazy` and arbitrary derivation reports.
8 changes: 4 additions & 4 deletions migration/v3-to-v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -8508,7 +8508,7 @@ effect/unstable/rpc/Utils (barrel: effect/unstable/rpc)

- `Arbitrary.ArbitraryGenerationContext` -> `Schema.Annotations.ToArbitrary.Context`: Use the v4 arbitrary-derivation context type from Schema.Annotations.

- `Arbitrary.LazyArbitrary` -> `Schema.LazyArbitrary`: The lazy arbitrary type moved onto Schema.
- `Arbitrary.LazyArbitrary` -> `Schema.Arbitrary`: The arbitrary factory type moved onto Schema.

#### `Arbitrary.make`

Expand All @@ -8519,19 +8519,19 @@ Arbitrary derivation is now exposed directly by Schema.
**Example**

```ts
Schema.toArbitrary(schema)
Schema.toArbitrary(schema)(FastCheck)
```

#### `Arbitrary.makeLazy`

**Replacement:** `Schema.toArbitraryLazy`
**Replacement:** `Schema.toArbitrary`

Lazy arbitrary derivation is now exposed directly by Schema.

**Example**

```ts
Schema.toArbitraryLazy(schema)
Schema.toArbitrary(schema)
```

### `effect/Array`
Expand Down
51 changes: 5 additions & 46 deletions packages/effect/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -5558,9 +5558,9 @@ console.log(JSON.stringify(document, null, 2))

### Generating an Arbitrary from a Schema

Property-based tests need generators. `Schema.toArbitrary` derives a
`fast-check` `Arbitrary` that generates decoded `Type` values accepted by the
schema.
Property-based tests need generators. `Schema.toArbitrary` derives a factory
that accepts the `fast-check` module and returns an `Arbitrary` that generates
decoded `Type` values accepted by the schema.

Most schemas do not need any extra work:

Expand All @@ -5573,23 +5573,11 @@ const Person = Schema.Struct({
age: Schema.Int.check(Schema.isBetween({ minimum: 18, maximum: 80 }))
})

const PersonArbitrary = Schema.toArbitrary(Person)
const PersonArbitrary = Schema.toArbitrary(Person)(FastCheck)

console.log(FastCheck.sample(PersonArbitrary, 3))
```

Use `Schema.toArbitraryLazy` only when you want the caller to provide
`fast-check`:

```ts
import { Schema } from "effect"
import { FastCheck } from "effect/testing"

const makeStringArbitrary = Schema.toArbitraryLazy(Schema.String)

const StringArbitrary = makeStringArbitrary(FastCheck)
```

`Schema.Never` and declaration schemas without a `toArbitrary` annotation cannot
be derived automatically.

Expand Down Expand Up @@ -5642,35 +5630,6 @@ This works because the final predicate check rejects strings that are not
palindromes. It may need many attempts, because the base string generator has no
reason to produce mirrored strings.

#### Reports

Use `{ report: true }` when you want to know which filters did not guide
generation:

```ts
import { Schema } from "effect"

const isPalindrome = (s: string) => s === Array.from(s).reverse().join("")

const Palindrome = Schema.String.check(
Schema.makeFilter(isPalindrome, {
expected: "a palindrome"
})
)

const result = Schema.toArbitrary(Palindrome, { report: true })

result.value
result.report.warnings
```

An `OpaqueFilter` warning means: "this filter is still checked, but it did not
help build the generator."

Reports contain warnings only. Unsupported schemas, impossible constraints,
invalid candidates, and recursive schemas without a finite terminal path still
fail immediately.

#### Custom Filters With Constraints

If part of a custom filter can be described as a normal generation constraint,
Expand Down Expand Up @@ -5947,7 +5906,7 @@ const Person = Schema.Struct({
company: Company
})

console.log(FastCheck.sample(Schema.toArbitrary(Person), 3))
console.log(FastCheck.sample(Schema.toArbitrary(Person)(FastCheck), 3))
```

These overrides are useful because the values have domain shape: names look like
Expand Down
128 changes: 23 additions & 105 deletions packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import type * as SchemaRepresentation from "./SchemaRepresentation.ts"
import * as SchemaTransformation from "./SchemaTransformation.ts"
import type { Assign, Lambda, Mutable, Simplify } from "./Struct.ts"
import * as Struct_ from "./Struct.ts"
import * as FastCheck from "./testing/FastCheck.ts"
import type * as FastCheck from "./testing/FastCheck.ts"
import type { RequiredKeys, UnionToIntersection } from "./Types.ts"
import type { Unify } from "./Unify.ts"

Expand Down Expand Up @@ -14507,81 +14507,53 @@ export const TaggedError: {
// -----------------------------------------------------------------------------

/**
* A thunk that, given the `fast-check` module, returns an `Arbitrary<T>`.
* Use this type when you need to defer instantiation of the arbitrary, for
* example to support recursive schemas.
* Represents a function that builds a fast-check `Arbitrary<T>` from the
* `fast-check` module.
*
* **When to use**
*
* Use as the result type of schema arbitrary derivation.
*
* @category utility types
* @since 4.0.0
*/
export type LazyArbitrary<T> = (fc: typeof FastCheck) => FastCheck.Arbitrary<T>
export type Arbitrary<T> = (fc: typeof FastCheck) => FastCheck.Arbitrary<T>

/**
* Derives a {@link LazyArbitrary} from a schema. The result is memoized so
* repeated calls with the same schema are cheap.
* Returns an {@link Arbitrary} factory derived from a schema. The generated
* values satisfy the schema and use its decoded `Type`.
*
* **Details**
*
* Prefer {@link toArbitrary} when you need the arbitrary directly, or when you
* want derivation diagnostics via `{ report: true }`. Unsupported schema
* nodes, impossible constraints, invalid candidates, and recursive schemas
* without a finite terminal path fail immediately.
* **When to use**
*
* @category generators
* @since 4.0.0
*/
export function toArbitraryLazy<S extends Constraint>(schema: S): LazyArbitrary<S["Type"]> {
const lawc = InternalArbitrary.memoized(schema.ast)
return (fc) => lawc(fc, {})
}

/**
* Derives a `fast-check` `Arbitrary` from a schema for property-based
* testing. The derived arbitrary generates values that satisfy the schema.
* Use when you need a fast-check generator for values accepted by a schema.
*
* **Details**
*
* Constraints refine base generators; candidates add weighted sources while
* filters still validate every value. `{ report: true }` returns warnings such
* as `OpaqueFilter`, while derivation errors remain fail-fast. Recursive
* schemas use terminal branches and fail when no finite terminal path exists.
* filters still validate every value. Recursive schemas use terminal branches
* and fail when no finite terminal path exists. The result is memoized so
* repeated calls with the same schema are cheap.
*
* **Example** (Generating arbitrary values)
*
* ```ts import.meta.vitest
* import { Schema } from "effect"
* import * as FastCheck from "fast-check"
*
* const PersonArb = Schema.toArbitrary(
* const makePersonArbitrary = Schema.toArbitrary(
* Schema.Struct({ name: Schema.String, age: Schema.Number })
* )
*
* // Sample a random value
* FastCheck.sample(PersonArb, 1)
* const PersonArbitrary = makePersonArbitrary(FastCheck)
* FastCheck.sample(PersonArbitrary, 1)
* ```
*
* @category generators
* @since 4.0.0
*/
export function toArbitrary<S extends Constraint>(schema: S): FastCheck.Arbitrary<S["Type"]>
export function toArbitrary<S extends Constraint>(
schema: S,
options: { readonly report: true }
): Annotations.ToArbitrary.WithReport<FastCheck.Arbitrary<S["Type"]>>
export function toArbitrary<S extends Constraint>(
schema: S,
options?: { readonly report?: boolean }
): FastCheck.Arbitrary<S["Type"]> | Annotations.ToArbitrary.WithReport<FastCheck.Arbitrary<S["Type"]>> {
if (options?.report === true) {
const lawc = InternalArbitrary.memoized(schema.ast)
const report = InternalArbitrary.makeReport()
InternalArbitrary.collectReport(schema.ast, report)
return {
value: lawc(FastCheck, {}),
report: InternalArbitrary.toReport(report)
}
}
return toArbitraryLazy(schema)(FastCheck)
export function toArbitrary<S extends Constraint>(schema: S): Arbitrary<S["Type"]> {
const lawc = InternalArbitrary.memoized(schema.ast)
return (fc) => lawc(fc, {})
}

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -16369,8 +16341,7 @@ export declare namespace Annotations {

/**
* Types used by arbitrary-derivation annotations to configure `toArbitrary`
* hooks, filter hints, candidate sources, diagnostics, and merged generation
* constraints.
* hooks, filter hints, candidate sources, and merged generation constraints.
*
* @since 4.0.0
*/
Expand All @@ -16383,8 +16354,7 @@ export declare namespace Annotations {
* `constraint` refines the schema node's base generator. `candidate` adds a
* weighted source before all filters run. If neither hint is provided, the
* filter does not guide generation; generated values are still checked by
* the filter predicate. With `{ report: true }`, this is reported as
* `OpaqueFilter`.
* the filter predicate.
*
* @category models
* @since 4.0.0
Expand Down Expand Up @@ -16572,58 +16542,6 @@ export declare namespace Annotations {
typeParameters: { readonly [K in keyof TypeParameters]: TypeParameter<TypeParameters[K]["Type"]> }
): (fc: typeof FastCheck, context: Context) => Output<T>
}

/**
* Wraps a derived value together with arbitrary-derivation diagnostics.
*
* @category models
* @since 4.0.0
*/
export interface WithReport<A> {
readonly value: A
readonly report: Report
}

/**
* Diagnostics collected while deriving an arbitrary.
*
* **Details**
*
* Reports contain warnings only. Unsupported schema nodes, impossible
* constraints, invalid candidate weights, and throwing candidate factories
* fail immediately.
*
* @category models
* @since 4.0.0
*/
export interface Report {
readonly warnings: ReadonlyArray<Warning>
}

/**
* Non-fatal arbitrary-derivation warning.
*
* @category models
* @since 4.0.0
*/
export type Warning = OpaqueFilterWarning

/**
* Warning emitted when a filter is handled only by the final `.filter`.
*
* **Details**
*
* The filter is still enforced. The warning means it did not contribute
* a constraint or candidate, so generation may rely on fast-check discards.
*
* @category models
* @since 4.0.0
*/
export interface OpaqueFilterWarning {
readonly _tag: "OpaqueFilter"
readonly path: ReadonlyArray<PropertyKey>
readonly description?: string | undefined
}
}

/**
Expand Down
72 changes: 0 additions & 72 deletions packages/effect/src/internal/schema/toArbitrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,6 @@ type LazyOption<T> = (
recursionStack: RecursionStack
) => FastCheck.Arbitrary<T> | undefined

export interface MutableReport {
readonly warnings: Array<Schema.Annotations.ToArbitrary.Warning>
}

/** @internal */
export function makeReport(): MutableReport {
return { warnings: [] }
}

/** @internal */
export function toReport(report: MutableReport): Schema.Annotations.ToArbitrary.Report {
return { warnings: report.warnings.slice() }
}

function arbitraryError(what: string) {
return new Error(`Unable to derive an arbitrary for ${what}`)
}
Expand Down Expand Up @@ -394,64 +380,6 @@ function finiteNumberContext(ctx: Context): Context {
}
}

function reportChecks(report: MutableReport, checks: SchemaAST.Checks | undefined, path: ReadonlyArray<PropertyKey>) {
function visit(check: SchemaAST.Check<any>, covered: boolean) {
const arbitrary = check.annotations?.arbitrary
const nextCovered = covered || arbitrary?.constraint !== undefined || arbitrary?.candidate !== undefined
if (check._tag !== "Filter") {
for (const child of check.checks) {
visit(child, nextCovered)
}
} else if (!nextCovered) {
const description = check.annotations?.representation?.id ?? check.annotations?.identifier ??
check.annotations?.expected
report.warnings.push({ _tag: "OpaqueFilter", path, ...(description === undefined ? {} : { description }) })
}
}
checks?.forEach((check) => visit(check, false))
}

/** @internal */
export function collectReport(ast: SchemaAST.AST, report: MutableReport) {
const stack = new WeakSet<SchemaAST.AST>()
function visit(ast: SchemaAST.AST, path: ReadonlyArray<PropertyKey>) {
if (stack.has(ast)) {
return
}
stack.add(ast)
reportChecks(report, ast.checks, path)
switch (ast._tag) {
case "Declaration":
ast.typeParameters.forEach((tp) => visit(tp, path))
break
case "Arrays": {
for (const [i, type] of [...ast.elements, ...ast.rest].entries()) {
visit(type, [...path, i])
}
break
}
case "Objects":
ast.propertySignatures.forEach((ps) => visit(ps.type, [...path, ps.name]))
ast.indexSignatures.forEach((is) => {
visit(is.parameter, path)
visit(is.type, path)
})
break
case "Union":
ast.types.forEach((type) => visit(type, path))
break
case "TemplateLiteral":
ast.parts.forEach((part, i) => visit(SchemaAST.toEncoded(part), [...path, i]))
break
case "Suspend":
visit(ast.thunk(), path)
break
}
stack.delete(ast)
}
visit(ast, [])
}

function applyCandidates(
fc: typeof FastCheck,
ctx: Context,
Expand Down
Loading
Loading