Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Giulio Canti <giulio.canti@gmail.com>
58 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 |
concurrency annotation |
see concurrency and batching | manual |
batching annotation |
— (see concurrency and batching) | removed |
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
annotations → annotate
Schema.Annotations.Annotations accepts any string key, so renaming .annotations(...) to .annotate(...) still type-checks when the object contains v3 annotations that v4 removed, such as concurrency, batching, or parseOptions. v4 keeps those keys as metadata but never reads them. Migrate them as described in concurrency and batching and parseOptions annotation instead of renaming them.
*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))(allowsundefined)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: makesoptionalKeyfields required; makesoptionalfields required asT | undefinedSchema.required: makesoptionalfields required (removesundefined)
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→ useoptionalKeyinstead ofoptionalnullable: true→ wrap inner schema inNullOrand filter nulls viaOption.filter(Predicate.isNotNull)default→ usewithDecodingDefaultType(orwithDecodingDefaultTypeKeywithexact: 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— successfalse— generic failurestring— failure with that messageSchemaIssue.Issue— a fully-formed issue{ path, issue }— failure at a nested path (issueis astringorSchemaIssue.Issue)ReadonlyArray<Schema.FilterIssue>— several failures reported together (empty array = success, single element is unwrapped, otherwise grouped into anIssue.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, Option, Schema, SchemaGetter, SchemaIssue } from "effect"
const NumberFromString = Schema.String.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transformEffect((s) =>
Option.match(Number.parse(s), {
onNone: () => Effect.fail(new SchemaIssue.InvalidValue()),
onSome: Effect.succeed
})
),
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")))
concurrency and batching annotations
Migration: manual
v4 removes the concurrency and batching schema annotations and has no annotation-based replacement. Because annotate accepts any key, .annotate({ concurrency: "unbounded", batching: true }) still compiles and is ignored at runtime: parsing runs sequentially, so requests that sibling children make with Effect.request are no longer pending at the same time and stop sharing batches.
Warning: Losing these annotations does not cause an error. It shows up as slower parsing and more backend calls. When a schema decodes many values through
Effect.request(for example, while loading startup data) and each child awaits one request, a parse running on its own sends every request in its own batch, and a step that used to take seconds can take minutes. Before upgrading, search forconcurrency:andbatching:in.annotations(...)calls, plus any calls toAST.getConcurrencyAnnotationorAST.getBatchingAnnotation.
In v3:
- the annotations applied only to the annotated tuple, array, struct, record, or union node, and nested schemas needed their own annotations;
batching: truebatched requests from the node's children even when they were parsed sequentially;concurrencywithoutbatchingdid not batch requests.
In v4:
- Concurrency is a parse option. Pass
concurrency(a number or"unbounded") when you create a decoder, encoder, or parser (Schema.decodeUnknownEffect(schema, { concurrency })) or on each call (decode(input, { concurrency })). Constructors take it asSchemaParser.makeEffect(schema)(input, { parseOptions: { concurrency } }). The default is sequential. - The option covers the whole parse. Every tuple, array, struct, record, and struct-with-rest node enforces the limit on its own children, so nested products can run more children at once than the limit. Union members are still tried one at a time, unlike v3, but products inside the member being tried use the option.
- There is no batching option.
Effect.withRequestBatchingand thebatchingoption ofEffect.forEachare gone too. ARequestResolverbatches whatever requests are pending for it when its delay ends (Effect.yieldNowby default, adjustable withRequestResolver.setDelay). The parse option only decides whether sibling children are traversed together. With sequential parsing, children that each await one request send them in separate batches when nothing else uses the resolver; with concurrent parsing, those requests are pending together and can share a batch. Requests started concurrently inside one transformation (for example withEffect.all(..., { concurrency: "unbounded" })), or by other fibers and parses using the same resolver, still batch under sequential parsing, so do not add parse concurrency only to keep batching you already have. - Batch sizes are not guaranteed. A batch holds whatever is pending when the resolver's delay ends, which depends on each node's concurrency limit, on how nested products overlap, on other effects that run before a request, and on the resolver's settings (
RequestResolver.batchN, batch keys, and the delay). Bound backend batch sizes with the resolver, not with the parse option. - Concurrency affects more than requests. Every effectful transformation, check, and constructor default of sibling children runs concurrently. Issues are collected in completion order, and with
errors: "first"the first failure to complete interrupts the remaining children, which may not be the earliest position.
v3
import { Schema } from "effect"
// Decodes a user id by calling Effect.request
declare const UserFromId: Schema.Schema<string, number>
const Users = Schema.Array(UserFromId).annotations({ concurrency: "unbounded", batching: true })
const program = Schema.decodeUnknown(Users)([1, 2, 3])
v4
import { Effect, Exit, Request, RequestResolver, Schema, SchemaGetter } from "effect"
interface GetUser extends Request.Request<string> {
readonly _tag: "GetUser"
readonly id: number
}
const GetUser = Request.tagged<GetUser>("GetUser")
const UserResolver = RequestResolver.make<GetUser>((entries) =>
Effect.sync(() => {
// One backend call per batch
for (const entry of entries) {
entry.completeUnsafe(Exit.succeed(`user-${entry.request.id}`))
}
})
)
const UserFromId = Schema.Number.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transformEffect((id: number) => Effect.request(GetUser({ id }), UserResolver)),
encode: SchemaGetter.forbidden(() => "Encoding is not supported")
})
)
// Do not move the v3 keys to `annotate`: they are accepted and ignored
const Users = Schema.Array(UserFromId)
// Options given to the decoder factory apply to every call
const decodeUsers = Schema.decodeUnknownEffect(Users, { concurrency: "unbounded" })
// The three requests are pending together and resolve in one batch
const program = decodeUsers([1, 2, 3])
Without { concurrency: "unbounded" }, the same program resolves three batches of one request each. With { concurrency: 2 }, the array has at most two of its requests pending at a time, but the limit is per node: requests from other products in the same parse can land in the same batch.
parseOptions annotation
Migration: manual
v4 removes the parseOptions annotation and has no annotation-based replacement. .annotate({ parseOptions: { onExcessProperty: "error" } }) still compiles and is ignored at runtime, like the concurrency and batching annotations.
Warning: Losing this annotation does not cause an error. A nested struct that refused unknown keys now accepts them and drops them from the decoded value. Search for
parseOptionsin.annotations(...)calls, plus any calls toAST.getParseOptionsAnnotation.
In v3, the annotation set onExcessProperty, errors, exact or propertyOrder for the annotated node and its children, whatever options the decoder was called with.
In v4, pass onExcessProperty and errors when you create a decoder, encoder, or parser, or on each call. They apply to the whole parse, and there is no per-node override.
The exact and propertyOrder parse options were removed. Model required and optional keys in the schema itself. Output key order is unspecified; handle any required presentation or serialization order outside the parser.
v3
import { Schema } from "effect"
const Inner = Schema.Struct({ x: Schema.String }).annotations({
parseOptions: { onExcessProperty: "error" }
})
const Outer = Schema.Struct({ inner: Inner })
// Left: the unknown key `y` is refused, although the decoder ignores excess properties by default
Schema.decodeUnknownEither(Outer)({ inner: { x: "a", y: 1 } })
v4
import { Schema } from "effect"
const Inner = Schema.Struct({ x: Schema.String })
const Outer = Schema.Struct({ inner: Inner })
// The option now applies to every struct in the parse, `Outer` included
const decodeOuter = Schema.decodeUnknownExit(Outer, { onExcessProperty: "error" })
// Failure: the unknown key `y` is refused
decodeOuter({ inner: { x: "a", y: 1 } })
If only some structs must refuse unknown keys, decode their raw inputs separately with { onExcessProperty: "error" }, before a surrounding parse can strip the extra keys.
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)
})
)
)
}
JSON Schema output
Migration: manual
JSONSchema.make becomes Schema.toJsonSchemaDocument (see JSONSchema.make). Some schemas also produce a looser JSON Schema than in v3. The v4 document describes the encoded side of the schema's JSON codec (Schema.toCodecJson), so it accepts the values that codec writes. In v3, the document described the schema's own encoded side.
| v3 schema | v3 JSONSchema.make |
v4 Schema.toJsonSchemaDocument |
|---|---|---|
Number |
{ "type": "number" } |
number, or one of the strings "Infinity", "-Infinity", "NaN" |
optional(String) |
{ "type": "string" }, not required |
string or null, not required |
Struct({ ... }) |
"additionalProperties": false |
"additionalProperties": true, unless onExcessProperty: "error" is set |
A document that passes the v4 JSON Schema can therefore fail Schema.decodeUnknown*(schema): that decoder refuses "NaN" for Schema.Number and null for Schema.optional(Schema.String). Decoding through Schema.toCodecJson(schema) accepts them.
To keep the v3 output:
- use
Schema.Finitefor a number that is neverNaNor infinite: it produces{ "type": "number" }; - use
Schema.optionalKeyfor a key that is either absent or present with a value: it does not addnull; - pass
{ onExcessProperty: "error" }to the generator, and to the decoder, to close objects.
If you publish JSON Schemas or compare them with earlier output, compare which documents they accept and refuse rather than their text. Built-in schemas such as NonEmptyString no longer carry their v3 description, title, and identifier annotations, so they can appear inline without that metadata. Schemas annotated with an identifier still produce named definitions and $ref references. The returned document stores those definitions separately in definitions; the root schema refers to them through #/$defs/....
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.ToArbitrarycontract and declaration-leveltoArbitraryannotation; - the legacy
arbitraryfilter annotation; - raw fast-check arbitrary inputs and
fastCheckoptions 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
SampleErrororExhausted; - 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
- Replace
effect/testing/FastCheckimports. Use the native Arbitrary module for Schema generation and import"fast-check"directly only where it is still independently required. - Replace
Schema.toArbitrary(schema)(FastCheck)withArbitrary.schema(schema). - Replace
FastCheck.samplewithArbitrary.sampleEffectand run the returned Effect. - Replace
FastCheck.checkorFastCheck.assertfor Schema-derived inputs withArbitrary.checkEffect, then handle its structured result. - Rename
@effect/vitestoptions fromfastChecktoarbitraryand convertnumRunstoruns. - Replace raw fast-check inputs in
@effect/vitestwith Schemas or native Arbitraries. - Migrate declaration-level
toArbitrarycallbacks to thetoCodecArbitraryLink-returning contract and replace old filter-levelarbitraryannotations witharbitraryConstraint. - Re-run properties with the native engine and record new replay tokens or explicit regression examples.
- Review discard limits for selective custom filters.