t3-code-android-nightly/.repos/effect-smol/migration/schema.md
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

48 KiB

Schema: Migration from v3

This document maps v3 Schema APIs to their v4 equivalents. Simple renames and argument changes are covered in the summary table below. More complex migrations have dedicated sections with code examples.

Migration types

  • auto — mechanical find-and-replace, safe to auto-apply
  • semi-auto — follows a clear pattern but needs structural changes
  • manual — requires case-by-case decisions, flag for human review
  • removed — no v4 equivalent

Summary table

v3 API v4 API Migration type
asSchema(schema) revealCodec(schema) rename
encodedSchema(schema) toEncoded(schema) rename
typeSchema(schema) toType(schema) rename
compose(schemaB) decodeTo(schemaB) rename
annotations(ann) annotate(ann) rename
decodingFallback annotation catchDecoding(...) rename
parseJson() fromJsonString(Unknown) rename
parseJson(schema) fromJsonString(schema) rename
pattern(regex) check(isPattern(regex)) rename
nonEmptyString isNonEmpty rename
BigIntFromSelf BigInt rename
SymbolFromSelf Symbol rename
URLFromSelf URL rename
RedactedFromSelf Redacted rename
Redacted RedactedFromValue rename
EitherFromSelf Result rename
DateFromNumber DateFromMillis rename
Date DateFromString restructure
decodeUnknown decodeUnknownEffect rename
decode decodeEffect rename
decodeUnknownEither decodeUnknownExit rename
decodeEither decodeExit rename
encodeUnknown encodeUnknownEffect rename
encode encodeEffect rename
encodeUnknownEither encodeUnknownExit rename
encodeEither encodeExit rename
asserts(schema)(input) asserts(schema, input) semi-auto
Literal(null) Null restructure
Literal("a", "b") Literals(["a", "b"]) variadic-to-array
pickLiteral("a", "b") Literals(...).pick(["a", "b"]) restructure
Union(A, B) Union([A, B]) variadic-to-array
Tuple(A, B) Tuple([A, B]) variadic-to-array
TemplateLiteral(A, B) TemplateLiteral([A, B]) variadic-to-array
TemplateLiteralParser(A, B) TemplateLiteralParser(schema.parts) restructure
Record({ key, value }) Record(key, value) restructure
filter(predicate) check(makeFilter(predicate)) restructure
filter(refinement) refine(refinement) restructure
UUID String.check(isUUID()) restructure
ULID String.check(isULID()) restructure
pick("a") mapFields(Struct.pick(["a"])) restructure
omit("a") mapFields(Struct.omit(["a"])) restructure
partial mapFields(Struct.map(Schema.optional)) restructure
partialWith({ exact: true }) mapFields(Struct.map(Schema.optionalKey)) restructure
required(schema) schema.mapFields(Struct.map(Schema.requiredKey)) restructure
extend(structB) mapFields(Struct.assign(fieldsB)) or fieldsAssign(fieldsB) restructure
transform(from, to, { decode, encode }) from.pipe(decodeTo(to, SchemaTransformation.transform({ decode, encode }))) restructure
transformOrFail(from, to, { decode, encode }) from.pipe(decodeTo(to, { decode: SchemaGetter.transformEffect(...), ... })) restructure
transformLiteral(from, to) Literal(from).transform(to) restructure
transformLiterals([0,"a"], [1,"b"]) Literals([0, 1]).transform(["a", "b"]) restructure
attachPropertySignature("k", "v") mapFields(f => ({...f, k: tagDefaultOmit("v")})) restructure
validate* removed (use decode* + toType) removed
keyof — removed
NonEmptyArrayEnsure — removed
withDefaults — removed
Data(schema) — removed
optionalWith(schema, opts) varies by options (see optionalWith) manual
optionalToOptional see optional field transformations manual
optionalToRequired see optional field transformations manual
requiredToOptional see optional field transformations manual
filterEffect see filterEffect manual
fromKey see rename manual
rename({ a: "c" }) see rename manual
format(schema) see format manual
ParseResult.ArrayFormatter.formatError(error) see ParseResult formatters manual
declare see declare manual

Additional rename notes

*FromSelf renames

The following *FromSelf schemas have been renamed to drop the suffix:

DateFromSelf → Date, DurationFromSelf → Duration, ChunkFromSelf → Chunk, ReadonlyMapFromSelf → ReadonlyMap, ReadonlySetFromSelf → ReadonlySet, HashMapFromSelf → HashMap, HashSetFromSelf → HashSet, BigDecimalFromSelf → BigDecimal, CauseFromSelf → Cause, ExitFromSelf → Exit, OptionFromSelf → Option, RegExpFromSelf → RegExp

Date encoded contract

Migration: restructure

In v3, Schema.Date decoded an ISO date string to a Date and rejected invalid dates. In v4, Schema.Date is the renamed Schema.DateFromSelf, so it expects a valid Date as its encoded value. Existing code can still type-check after upgrading while no longer accepting the same input.

v3

import { Schema } from "effect"

const DateFromIsoString = Schema.Date

v4

import { Schema } from "effect"

const DateFromIsoString = Schema.DateFromString

Schema.DateFromString preserves the string-to-Date transformation and rejects strings that produce invalid dates.

Filter renames

All filters have been renamed with an is prefix and now use check(...) or pipe(Schema.check(...)):

greaterThan → isGreaterThan, greaterThanOrEqualTo → isGreaterThanOrEqualTo, lessThan → isLessThan, lessThanOrEqualTo → isLessThanOrEqualTo, between → isBetween, int → isInt, multipleOf → isMultipleOf, finite → isFinite, minLength → isMinLength, maxLength → isMaxLength, length → isBetweenLength

Note: positive, negative, nonNegative, nonPositive have been removed in v4.

Utility renames

equivalence → toEquivalence, arbitrary → Arbitrary.schema from effect/Arbitrary, pretty → toFormatter, standardSchemaV1 → toStandardSchemaV1

Detailed migrations

Redacted

Migration: rename with behavior distinction

In v3, Schema.Redacted(value) decoded the raw encoded value and wrapped the decoded value in Redacted.

In v4, that behavior is named Schema.RedactedFromValue(value).

v3

import { Schema } from "effect"

const schema = Schema.Redacted(Schema.String)
const decode = Schema.decodeSync(schema)

decode("secret")

v4

import { Redacted, Schema } from "effect"

const schema = Schema.RedactedFromValue(Schema.String)
const decode = Schema.decodeSync(schema)

const redacted = decode("secret")
console.log(Redacted.value(redacted))
// secret

Schema.Redacted(value) in v4 is the replacement for v3 Schema.RedactedFromSelf(value): it expects the input to already be a Redacted value, so both Type and Encoded are Redacted<...>.

asserts signature

Migration: semi-auto

Schema.asserts now asserts an input directly instead of returning an assertion function.

v3

import { Schema } from "effect"

const assertString = Schema.asserts(Schema.String)
assertString(input)

v4

import { Schema } from "effect"

Schema.asserts(Schema.String, input)

validate* removal

Migration: removed

The validate, validateEither, validatePromise, validateSync, and validateOption APIs have been removed. Use Schema.decode* + Schema.toType instead.

import { Schema } from "effect"

// v3: Schema.validateSync(Schema.String)(input)
// v4:
const validateSync = Schema.decodeSync(Schema.toType(Schema.String))

Data removal

Migration: removed

Schema.Data has no v4 equivalent. Remove it. Equal.equals performs deep structural comparison on objects by default in v4, so Schema.Data is unnecessary.

pickLiterals

Migration: auto

v3

import { Schema } from "effect"

const schema = Schema.Literal("a", "b", "c").pipe(Schema.pickLiteral("a", "b"))

v4

import { Schema } from "effect"

const schema = Schema.Literals(["a", "b", "c"]).pick(["a", "b"])

TemplateLiteralParser

Migration: semi-auto

v3

import { Schema } from "effect"

const schema = Schema.TemplateLiteral(Schema.String, ".", Schema.String)
const parser = Schema.TemplateLiteralParser(Schema.String, ".", Schema.String)

v4

import { Schema } from "effect"

const schema = Schema.TemplateLiteral([Schema.String, ".", Schema.String])
// use the `parts` property instead of repeating the template parts
const parser = Schema.TemplateLiteralParser(schema.parts)

Behavior note: TemplateLiteral and TemplateLiteralParser match parts semantically. Checks on string, number, and bigint schema parts are applied while matching each segment, so refined parts can reject strings that would match the broader primitive shape.

format

Migration: manual

New imports: SchemaRepresentation

v3

import { Schema } from "effect"

console.log(Schema.format(Schema.String))
// string

v4

import { Schema, SchemaRepresentation } from "effect"

const doc = SchemaRepresentation.toRepresentation(Schema.String.ast)
const multi = SchemaRepresentation.toMultiDocument(doc)
const codeDoc = SchemaRepresentation.toCodeDocument(multi)
console.log(codeDoc.codes[0].Type)
// string

ParseResult formatters

Migration: manual

New imports: SchemaIssue

In v4, schema parsing fails with Schema.SchemaError, which contains a nested SchemaIssue in its issue field.

Use SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues for the v3 ParseResult.ArrayFormatter.formatError(error) equivalent.

v3

import { Either, ParseResult, Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Number
})

const decode = Schema.decodeUnknownEither(Person)

const result = decode({})
if (Either.isLeft(result)) {
  console.error("Decoding failed:")
  console.error(ParseResult.ArrayFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
[ { _tag: 'Missing', path: [ 'name' ], message: 'is missing' } ]
*/

v4

import { Schema, SchemaIssue } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Number
})

const decode = Schema.decodeUnknownSync(Person)

try {
  decode({})
} catch (error) {
  if (error instanceof Error) {
    console.error("Decoding failed:")
    if (SchemaIssue.isIssue(error.cause)) {
      console.error(SchemaIssue.makeFormatterStandardSchemaV1()(error.cause).issues)
    }
  }
}
/*
Decoding failed:
[ { path: [ 'name' ], message: 'Missing key' } ]
*/

Record

Migration: auto

v3

import { Schema } from "effect"

const schema = Schema.Record({ key: Schema.String, value: Schema.Number })

v4

import { Schema } from "effect"

const schema = Schema.Record(Schema.String, Schema.Number)

Behavior note: dynamic record key schemas select matching own properties before the value schema is applied. Refined key schemas such as Schema.String.check(...), Schema.Int, or checked template literals ignore properties that do not match the key schema; they do not validate the value at those ignored keys. For transformed key schemas, selection is based on encoded property names before selected keys are decoded.

pick / omit

Migration: semi-auto

New imports: Struct

v3

import { Schema } from "effect"

const picked = Schema.Struct({ a: Schema.String, b: Schema.Number }).pipe(Schema.pick("a"))
const omitted = Schema.Struct({ a: Schema.String, b: Schema.Number }).pipe(Schema.omit("b"))

v4

import { Schema, Struct } from "effect"

const picked = Schema.Struct({ a: Schema.String, b: Schema.Number }).mapFields(Struct.pick(["a"]))
const omitted = Schema.Struct({ a: Schema.String, b: Schema.Number }).mapFields(Struct.omit(["b"]))

partial / partialWith

Migration: semi-auto

New imports: Struct

  • Schema.partial → mapFields(Struct.map(Schema.optional)) (allows undefined)
  • Schema.partialWith({ exact: true }) → mapFields(Struct.map(Schema.optionalKey)) (exact)
import { Schema, Struct } from "effect"

const struct = Schema.Struct({ a: Schema.String, b: Schema.Number })

// v3: struct.pipe(Schema.partial)
const withUndefined = struct.mapFields(Struct.map(Schema.optional))

// v3: struct.pipe(Schema.partialWith({ exact: true }))
const exact = struct.mapFields(Struct.map(Schema.optionalKey))

You can also make a subset of fields partial:

import { Schema, Struct } from "effect"

const schema = Schema.Struct({ a: Schema.String, b: Schema.Number })
  .mapFields(Struct.mapPick(["a"], Schema.optional))

required

Migration: semi-auto

New imports: Struct

  • Schema.requiredKey: makes optionalKey fields required; makes optional fields required as T | undefined
  • Schema.required: makes optional fields required (removes undefined)
import { Schema, Struct } from "effect"

const original = Schema.Struct({
  a: Schema.optionalKey(Schema.String),
  b: Schema.optionalKey(Schema.Number)
})

// v3: Schema.required(original)
const schema = original.mapFields(Struct.map(Schema.requiredKey))
// { readonly a: string; readonly b: number; }

optional field transformations

Migration: manual

New imports: SchemaGetter

optionalToOptional, optionalToRequired, and requiredToOptional are all replaced by Schema.decodeTo + SchemaGetter.transformOptional.

The pattern: start with the encoded optionality (optionalKey or required), pipe to decodeTo with the decoded optionality, and provide transformOptional functions for decode/encode.

Example (v3 optionalToRequired: setting null as default for missing field)

v3

import { Option, Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalToRequired(Schema.String, Schema.NullOr(Schema.String), {
    decode: Option.getOrElse(() => null),
    encode: Option.liftPredicate((value) => value !== null)
  })
})

v4

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalKey(Schema.String).pipe(
    Schema.decodeTo(Schema.NullOr(Schema.String), {
      decode: SchemaGetter.transformOptional(Option.orElseSome(() => null)),
      encode: SchemaGetter.transformOptional(Option.filter((value) => value !== null))
    })
  )
})

Example (v3 requiredToOptional: empty string as missing value)

v3

import { Option, Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.requiredToOptional(Schema.String, Schema.String, {
    decode: Option.liftPredicate((s) => s !== ""),
    encode: Option.getOrElse(() => "")
  })
})

v4

import { Option, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  a: Schema.String.pipe(
    Schema.decodeTo(Schema.optionalKey(Schema.String), {
      decode: SchemaGetter.transformOptional(Option.filter((value) => value !== "")),
      encode: SchemaGetter.transformOptional(Option.orElseSome(() => ""))
    })
  )
})

optionalWith

Migration: manual

New imports: SchemaGetter, Predicate (for nullable variants)

Decision tree

v3 options v4 pattern
{ exact: true } optionalKey(schema)
{ default } schema.pipe(withDecodingDefaultType(...))
{ exact: true, default } schema.pipe(withDecodingDefaultTypeKey(...))
{ nullable: true } optional(NullOr(schema)) + decodeTo + filter null
{ nullable: true, exact: true } optionalKey(NullOr(schema)) + decodeTo + filter null
{ nullable: true, default } optional(NullOr(schema)) + decodeTo + filter null + orElseSome
{ nullable: true, exact: true, default } optionalKey(NullOr(schema)) + decodeTo + filter null + orElseSome

Key rules:

  • exact: true → use optionalKey instead of optional
  • nullable: true → wrap inner schema in NullOr and filter nulls via Option.filter(Predicate.isNotNull)
  • default → use withDecodingDefaultType (or withDecodingDefaultTypeKey with exact: true)

Example: { exact: true } (simplest case)

v3

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalWith(Schema.NumberFromString, { exact: true })
})

v4

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalKey(Schema.NumberFromString)
})

Example: { default }

v3

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalWith(Schema.String, { default: () => "" })
})

v4

import { Effect, Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.String.pipe(Schema.withDecodingDefaultType(Effect.succeed("")))
})

Example: { exact: true, default }

v3

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalWith(Schema.String, { exact: true, default: () => "" })
})

v4

import { Effect, Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.String.pipe(Schema.withDecodingDefaultTypeKey(Effect.succeed("")))
})

Example: { nullable: true, exact: true, default } (most complex case)

v3

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalWith(Schema.NumberFromString, { nullable: true, default: () => -1, exact: true })
})

v4

import { Option, Predicate, Schema, SchemaGetter } from "effect"

const schema = Schema.Struct({
  a: Schema.optionalKey(Schema.NullOr(Schema.NumberFromString)).pipe(
    Schema.decodeTo(Schema.Number, {
      decode: SchemaGetter.transformOptional((o) =>
        o.pipe(Option.filter(Predicate.isNotNull), Option.orElseSome(() => -1))
      ),
      encode: SchemaGetter.required()
    })
  )
})

pluck

Migration: manual

New imports: SchemaGetter, Struct

v3

import { Schema } from "effect"

const schema = Schema.Struct({ a: Schema.String, b: Schema.Number }).pipe(Schema.pluck("a"))

v4

import { Schema, SchemaGetter, Struct } from "effect"

function pluck<P extends PropertyKey>(key: P) {
  return <S extends Schema.Top>(
    schema: Schema.Struct<{ [K in P]: S }>
  ): Schema.decodeTo<Schema.toType<S>, Schema.Struct<{ [K in P]: S }>> => {
    return schema.mapFields(Struct.pick([key])).pipe(
      Schema.decodeTo(Schema.toType(schema.fields[key]), {
        decode: SchemaGetter.transform((whole: any) => whole[key]),
        encode: SchemaGetter.transform((value) => ({ [key]: value } as any))
      })
    )
  }
}

const schema = Schema.Struct({ a: Schema.String, b: Schema.Number }).pipe(pluck("a"))

extend

Migration: semi-auto

New imports: Struct (Struct case), Tuple (Union case)

Struct extends Struct

v3

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.String,
  b: Schema.Number
}).pipe(Schema.extend(Schema.Struct({ c: Schema.Number })))

v4

import { Schema, Struct } from "effect"

const schema = Schema.Struct({
  a: Schema.String,
  b: Schema.Number
}).mapFields(Struct.assign({ c: Schema.Number }))

// or more succinctly
const schema2 = Schema.Struct({
  a: Schema.String,
  b: Schema.Number
}).pipe(Schema.fieldsAssign({ c: Schema.Number }))

Union extends Struct

v3

import { Schema } from "effect"

const schema = Schema.Union(
  Schema.Struct({ a: Schema.String }),
  Schema.Struct({ b: Schema.Number })
).pipe(Schema.extend(Schema.Struct({ c: Schema.Boolean })))

v4

import { Schema, Tuple } from "effect"

const schema = Schema.Union([
  Schema.Struct({ a: Schema.String }),
  Schema.Struct({ b: Schema.Number })
]).mapMembers(Tuple.map(Schema.fieldsAssign({ c: Schema.Number })))

filter

Migration: semi-auto

v3

import { Schema } from "effect"

// inline filter
const a = Schema.String.pipe(Schema.filter((s) => s.length > 0))

// refinement
const b = Schema.Option(Schema.String).pipe(Schema.filter(Option.isSome))

v4

import { Option, Schema } from "effect"

// inline filter
const a = Schema.String.check(Schema.makeFilter((s) => s.length > 0))

// refinement
const b = Schema.Option(Schema.String).pipe(Schema.refine(Option.isSome))

In v4, a makeFilter predicate can return any of the shapes described by Schema.FilterOutput:

  • undefined / true — success
  • false — generic failure
  • string — failure with that message
  • SchemaIssue.Issue — a fully-formed issue
  • { path, issue } — failure at a nested path (issue is a string or SchemaIssue.Issue)
  • ReadonlyArray<Schema.FilterIssue> — several failures reported together (empty array = success, single element is unwrapped, otherwise grouped into an Issue.Composite)

Example (Failure at a nested path)

import { Schema } from "effect"

const schema = Schema.Struct({ password: Schema.String, confirmPassword: Schema.String }).check(
  Schema.makeFilter((o) =>
    o.password === o.confirmPassword
      ? undefined
      : { path: ["password"], issue: "password and confirmPassword must match" }
  )
)

console.log(String(Schema.decodeUnknownExit(schema)({ password: "123456", confirmPassword: "1234567" })))
// Failure(Cause([Fail(SchemaError: password and confirmPassword must match
//   at ["password"])]))

Example (Reporting multiple failures at once)

import { Schema } from "effect"

const schema = Schema.Struct({ a: Schema.Finite, b: Schema.Finite, c: Schema.Finite }).check(
  Schema.makeFilter((o) => {
    const issues: Array<Schema.FilterIssue> = []
    if (o.a > 0) {
      if (o.b <= 0) issues.push({ path: ["b"], issue: "b must be greater than 0" })
      if (o.c <= 0) issues.push({ path: ["c"], issue: "c must be greater than 0" })
    }
    return issues
  })
)

console.log(String(Schema.decodeUnknownExit(schema)({ a: 1, b: 0, c: 0 })))
// Failure(Cause([Fail(SchemaError: b must be greater than 0
//   at ["b"]
// c must be greater than 0
//   at ["c"])]))

filterEffect

Migration: manual

New imports: SchemaGetter, Result

v3

import { Effect, Schema } from "effect"

async function validateUsername(username: string) {
  return Promise.resolve(username === "gcanti")
}

const ValidUsername = Schema.String.pipe(
  Schema.filterEffect((username) =>
    Effect.promise(() => validateUsername(username).then((valid) => valid || "Invalid username"))
  )
)

v4

import { Effect, Result, Schema, SchemaGetter } from "effect"

async function validateUsername(username: string) {
  return Promise.resolve(username === "gcanti")
}

const ValidUsername = Schema.String.pipe(
  Schema.decode({
    decode: SchemaGetter.checkEffect((username) =>
      Effect.promise(() => validateUsername(username).then((valid) => valid || "Invalid username"))
    ),
    encode: SchemaGetter.passthrough()
  })
)

transform

Migration: semi-auto

New imports: SchemaTransformation

v3

import { Schema } from "effect"

const BooleanFromString = Schema.transform(Schema.Literal("on", "off"), Schema.Boolean, {
  strict: true,
  decode: (literal) => literal === "on",
  encode: (bool) => (bool ? "on" : "off")
})

v4

import { Schema, SchemaTransformation } from "effect"

const BooleanFromString = Schema.Literals(["on", "off"]).pipe(
  Schema.decodeTo(
    Schema.Boolean,
    SchemaTransformation.transform({
      decode: (literal) => literal === "on",
      encode: (bool) => (bool ? "on" : "off")
    })
  )
)

transformOrFail

Migration: semi-auto

New imports: SchemaGetter, SchemaIssue

v3

import { ParseResult, Schema } from "effect"

const NumberFromString = Schema.transformOrFail(Schema.String, Schema.Number, {
  strict: true,
  decode: (input, _, ast) => {
    const parsed = parseFloat(input)
    if (isNaN(parsed)) {
      return ParseResult.fail(new ParseResult.Type(ast, input, "Failed to convert string to number"))
    }
    return ParseResult.succeed(parsed)
  },
  encode: (input) => ParseResult.succeed(input.toString())
})

v4

import { Effect, Number, Schema, SchemaGetter, SchemaIssue } from "effect"

const NumberFromString = Schema.String.pipe(
  Schema.decodeTo(Schema.Number, {
    decode: SchemaGetter.transformEffect((s) => {
      const n = Number.parse(s)
      if (n === undefined) {
        return Effect.fail(new SchemaIssue.InvalidValue())
      }
      return Effect.succeed(n)
    }),
    encode: SchemaGetter.String()
  })
)

transformLiteral / transformLiterals

Migration: auto

v3

import { Schema } from "effect"

const a = Schema.transformLiteral(0, "a")
const b = Schema.transformLiterals([0, "a"], [1, "b"], [2, "c"])

v4

import { Schema } from "effect"

const a = Schema.Literal(0).transform("a")
const b = Schema.Literals([0, 1, 2]).transform(["a", "b", "c"])

attachPropertySignature

Migration: semi-auto

v3

import { Schema } from "effect"

const Circle = Schema.Struct({ radius: Schema.Number })
const Square = Schema.Struct({ sideLength: Schema.Number })

const DiscriminatedShape = Schema.Union(
  Circle.pipe(Schema.attachPropertySignature("kind", "circle")),
  Square.pipe(Schema.attachPropertySignature("kind", "square"))
)

v4

import { Schema } from "effect"

const Circle = Schema.Struct({ radius: Schema.Number })
const Square = Schema.Struct({ sideLength: Schema.Number })

const DiscriminatedShape = Schema.Union([
  Circle.mapFields((fields) => ({ ...fields, kind: Schema.tagDefaultOmit("circle") })),
  Square.mapFields((fields) => ({ ...fields, kind: Schema.tagDefaultOmit("square") }))
])

decodingFallback

Migration: auto

v3

import { Effect, Schema } from "effect"

const schema = Schema.String.annotations({
  decodingFallback: () => Effect.succeed("a")
})

v4

import { Effect, Schema } from "effect"

const schema = Schema.String.pipe(Schema.catchDecoding(() => Effect.succeedSome("a")))

rename

Migration: manual

New imports: SchemaTransformation

v3

import { Schema } from "effect"

const schema = Schema.Struct({
  a: Schema.String,
  b: Schema.Number
}).pipe(Schema.rename({ a: "c" }))

v4

import { Schema } from "effect"

// experimental API
const schema = Schema.Struct({
  a: Schema.String,
  b: Schema.Number
}).pipe(Schema.encodeKeys({ a: "c" }))

Capitalize / Lowercase / Uppercase / Uncapitalize

Migration: semi-auto

New imports: SchemaTransformation

v3

import { Schema } from "effect"

const schema = Schema.Capitalize

v4

import { Schema, SchemaTransformation } from "effect"

const schema = Schema.String.pipe(
  Schema.decodeTo(Schema.String.check(Schema.isCapitalized()), SchemaTransformation.capitalize())
)

NonEmptyTrimmedString

Migration: semi-auto

v3

import { Schema } from "effect"

const schema = Schema.NonEmptyTrimmedString

v4

import { Schema } from "effect"

const schema = Schema.Trimmed.check(Schema.isNonEmpty())

split

Migration: manual

New imports: SchemaTransformation

v3

import { Schema } from "effect"

const schema = Schema.split(",")

v4

import { Schema, SchemaTransformation } from "effect"

function split(separator: string) {
  return Schema.String.pipe(
    Schema.decodeTo(
      Schema.Array(Schema.String),
      SchemaTransformation.transform({
        decode: (s) => s.split(separator) as ReadonlyArray<string>,
        encode: (as) => as.join(separator)
      })
    )
  )
}

Migrating from the fast-check bridge to native Arbitrary

This section covers migration from the fast-check bridge published in effect@4.0.0-rc.109 to the native, Schema-first module at effect/Arbitrary.

The new module removes fast-check from the effect package. Applications may still install and use fast-check directly, but Effect Schema generation and @effect/vitest property tests no longer depend on it.

For the new API and its semantics, see Arbitrary in Effect.

Import changes

The following APIs have been removed:

  • effect/testing/FastCheck;
  • Schema.toArbitrary;
  • Schema.Arbitrary;
  • the legacy Schema.Annotations.ToArbitrary contract and declaration-level toArbitrary annotation;
  • the legacy arbitrary filter annotation;
  • raw fast-check arbitrary inputs and fastCheck options in @effect/vitest.

Import the native module explicitly:

import * as Arbitrary from "effect/Arbitrary"

If other tests still use fast-check-specific APIs, add fast-check as a direct development dependency and import it from "fast-check". Do not import it through Effect.

Generating samples

Previously, Schema.toArbitrary returned a factory that needed the fast-check module:

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

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Int
})

const personArbitrary = Schema.toArbitrary(Person)(FastCheck)
const samples = FastCheck.sample(personArbitrary, { numRuns: 20, seed: 42 })

Now derive and sample through the Effect-native module:

import { Effect, Schema } from "effect"
import * as Arbitrary from "effect/Arbitrary"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Int
})

const personArbitrary = Arbitrary.schema(Person)
const samples = await Effect.runPromise(
  Arbitrary.sampleEffect(personArbitrary, { count: 20, seed: 42 })
)

Arbitrary.sampleEffect returns an Effect because sampling is interruptible, uses Effect Random when no seed is provided, and reports bounded generation exhaustion as a typed SampleError.

The generated values still use the decoded Schema Type. The sequence and distribution are not compatible with fast-check, even when the same numeric seed is used.

Checking properties

Previously, fast-check owned both the property and the runner:

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

const integer = Schema.toArbitrary(Schema.Int)(FastCheck)

FastCheck.assert(
  FastCheck.property(integer, (value) => Number.isInteger(value)),
  { numRuns: 100, seed: 42 }
)

Now Arbitrary.checkEffect runs a pure or Effectful property and returns a structured result:

import { Effect, Schema } from "effect"
import * as Arbitrary from "effect/Arbitrary"

const result = await Effect.runPromise(
  Arbitrary.checkEffect(
    Arbitrary.schema(Schema.Int),
    (value) => Number.isInteger(value),
    { runs: 100, seed: 42 }
  )
)

Unlike FastCheck.assert, Arbitrary.checkEffect does not throw for an ordinary falsification. Handle Passed, Falsified, Exhausted, and ReplayMismatch explicitly, or use @effect/vitest, which converts non-passing results into test failures.

Typed failures from Effectful properties are preserved in Falsified.failure. Defects and interruption continue through the returned Effect.

Option mapping

The most common options map as follows:

Previous fast-check option Native option Migration note
numRuns count or runs Use count for sampleEffect and runs for checkEffect.
seed seed The type is compatible, but generated sequences are not.
path replay Existing fast-check paths cannot be converted.
maxSkipsPerRun maxDiscards Native uses one absolute discard budget, not a multiplier per run.
examples No direct equivalent Keep explicit regression cases as ordinary tests.
endOnFailure maxShrinks Use maxShrinks: 0 to stop at the initial failure.
interruptAfterTimeLimit Effect interruption Apply an Effect or test timeout around the check.
skipAllAfterTimeLimit No direct equivalent Prefer explicit run and discard bounds.
verbose No direct equivalent Inspect CheckResult or use @effect/vitest failure output.

Review any less common fast-check runner option manually. The native API deliberately does not reproduce the complete fc.Parameters surface.

Replay migration

Fast-check replay used a seed plus a shrink path. Native replay uses one opaque token returned by a Falsified result:

const replayed = Arbitrary.checkEffect(arbitrary, property, {
  replay: previousFailure.replay
})

There is no conversion from a fast-check seed and path to a native replay token. Re-run the property with the native engine, then record the new token from its Falsified result.

Replay tokens are intended for reproducing and diagnosing a current failure. Because the module is unstable, they are not guaranteed to survive upgrades. Preserve important failing inputs as explicit regression tests.

Array shrinking now tries deleting prefixes and interior blocks as well as suffixes. Products and objects retain child shrink candidates when exploring other branches. Shrunk outputs and replay paths can therefore differ from earlier native releases, including RC.115. Re-run affected properties to record new tokens. For arrays of composed Arbitraries, use Arbitrary.array(item, { maxLength: 50 }) instead of shrinking a generated length through flatMap and all; deletions then preserve the remaining generated elements.

Migrating declaration annotations

The old toArbitrary annotation directly constructed a fast-check arbitrary and exposed fast-check recursion and constraint details:

import { Schema } from "effect"

class UserId {
  readonly value: number
  constructor(value: number) {
    this.value = value
  }
}

const UserIdSchema = Schema.instanceOf(UserId, {
  toArbitrary: () => (fc) => fc.integer({ min: 1, max: 1_000_000 }).map((value) => new UserId(value))
})

The native toCodecArbitrary annotation describes a generatable representation as a Schema Link:

import { Schema, SchemaTransformation } from "effect"

class UserId {
  readonly value: number
  constructor(value: number) {
    this.value = value
  }
}

const UserIdSchema = Schema.instanceOf(UserId, {
  toCodecArbitrary: () =>
    Schema.link<UserId>()(
      Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_000_000 })),
      SchemaTransformation.transform({
        decode: (value) => new UserId(value),
        encode: (id) => id.value
      })
    )
})

Before adding toCodecArbitrary, check whether the declaration already has a useful toCodecJson or toCodec. Native derivation falls back to those canonical codecs automatically. Add an arbitrary-specific Link only when the canonical representation is opaque or generates valid values too rarely.

The migration changes where generation logic lives:

Legacy contract Native contract
Returns a fast-check.Arbitrary Returns a SchemaAST.Link through Schema.link.
Receives generated arbitrary type parameters Receives decoded Schema type parameters.
Receives fast-check constraints and recursion Receives normalized constraints.
Manages terminal recursive branches explicitly Leaves recursion analysis and budgets to the native compiler.
Uses arbitrary combinators Uses Schema constructors, checks, and a Schema transformation.

The original declaration remains authoritative. Values decoded by the Link are checked against it. Failed decodes and rejected values become bounded discards.

Custom filter metadata

The old arbitrary filter annotation has been replaced by arbitraryConstraint. Ordinary custom filters continue to work as residual filters without generation metadata:

import { Schema } from "effect"

const Even = Schema.Int.check(
  Schema.makeFilter((value) => value % 2 === 0)
)

Residual filtering is bounded, so a very selective or impossible predicate may produce SampleError or Exhausted.

If the previous annotation supplied a recognized constructive constraint, move it to arbitraryConstraint and adapt its shape. The predicate remains authoritative:

import { Order, Schema } from "effect"

const Positive = Schema.Number.check(
  Schema.makeFilter(
    (value) => value > 0,
    {
      arbitraryConstraint: {
        order: Order.Number,
        minimum: 0,
        exclusiveMinimum: true
      }
    }
  )
)

The main constraint-shape changes are:

Previous field Native field
ordered.order order
ordered.minimum / maximum minimum / maximum
ordered.exclusiveMinimum / maximum exclusiveMinimum: true / exclusiveMaximum: true
integer: true number: "integer"
noNaN and noInfinity number: "finite" when both restrictions apply
collection minLength / maxLength minLength, minSize, or minProperties and its matching maximum
string pattern { source, flags } in patterns
unique: true uniqueBy: identity
candidate No direct equivalent

Choose the cardinality field that matches the Schema domain: minLength and maxLength for strings and arrays, minSize and maxSize for sized collections, and minProperties and maxProperties for object properties.

For an opaque declaration that needs a reusable statistically better source domain, express that source as a Schema Link with toCodecArbitrary.

Migrating @effect/vitest

Property inputs may be Schemas, native Arbitraries, or mixtures of both.

Schema-only properties need only an option rename:

// Before
it.prop(
  "commutative",
  [Schema.Int, Schema.Int],
  ([a, b]) => a + b === b + a,
  { fastCheck: { numRuns: 200, seed: 42 } }
)

// After
it.prop(
  "commutative",
  [Schema.Int, Schema.Int],
  ([a, b]) => a + b === b + a,
  { arbitrary: { runs: 200, seed: 42 } }
)

Raw or mixed fast-check inputs are no longer accepted:

// No longer supported
it.prop("raw arbitrary", [fc.integer()], ([value]) => Number.isInteger(value))
it.prop("mixed", [Schema.String, fc.integer()], ([text, value]) => true)

Replace those inputs with Schemas when they describe a domain supported by Schema, or compose a native Arbitrary:

import * as Arbitrary from "effect/Arbitrary"

const integer = Arbitrary.schema(Schema.Int)

it.prop("native arbitrary", [integer], ([value]) => Number.isInteger(value))
it.prop("mixed", [Schema.String, integer], ([text, value]) => typeof text === "string" && Number.isInteger(value))

If a test genuinely needs a fast-check-specific arbitrary or runner feature, use fast-check directly with Vitest rather than passing it through @effect/vitest.

it.prop, it.effect.prop, and it.live.prop all accept native check options under arbitrary.

Behavioral differences to review

Migration is not only an import rename. Review the following differences:

  • native generation and shrinking have different distributions and may find different shrunk inputs;
  • native checking returns structured results instead of using fast-check's assertion exceptions;
  • generation that cannot find enough valid samples is bounded and reports SampleError or Exhausted;
  • pure and Effectful properties share one interruptible runner;
  • recursive and mutually recursive Schemas are analyzed as a graph and must have a finite generation path;
  • replay tokens, seeds, and shrink paths are not compatible with fast-check;
  • generated values are the decoded Schema Type;
  • properties must not mutate generated values.

Migration checklist

  1. Replace effect/testing/FastCheck imports. Use the native Arbitrary module for Schema generation and import "fast-check" directly only where it is still independently required.
  2. Replace Schema.toArbitrary(schema)(FastCheck) with Arbitrary.schema(schema).
  3. Replace FastCheck.sample with Arbitrary.sampleEffect and run the returned Effect.
  4. Replace FastCheck.check or FastCheck.assert for Schema-derived inputs with Arbitrary.checkEffect, then handle its structured result.
  5. Rename @effect/vitest options from fastCheck to arbitrary and convert numRuns to runs.
  6. Replace raw fast-check inputs in @effect/vitest with Schemas or native Arbitraries.
  7. Migrate declaration-level toArbitrary callbacks to the toCodecArbitrary Link-returning contract and replace old filter-level arbitrary annotations with arbitraryConstraint.
  8. Re-run properties with the native engine and record new replay tokens or explicit regression examples.
  9. Review discard limits for selective custom filters.