Schema
Schema is a TypeScript-first library for defining data shapes, validating unknown input, and transforming values between formats.
Two key concepts appear throughout this guide:
- Decoding — turning unknown external data (API responses, form submissions, config files) into typed, validated values.
- Encoding — turning typed values back into a serializable format (JSON, FormData, etc.).
Use Schema to:
- Define types — declare the shape of your data once and get both the TypeScript type and a runtime validator.
- Validate input — decode unknown data into type-safe values, with clear error messages when it doesn’t match.
- Transform values — convert between your domain types and serialization formats like JSON, FormData, and URLSearchParams.
- Generate tooling — derive JSON Schemas, test data generators, equivalence checks, and more from a single schema definition.
Design Philosophy
Section titled “Design Philosophy”- Lightweight by default — only import the features you need, keeping your bundle small.
- Familiar API — naming conventions and patterns are consistent with popular validation libraries, so getting started is easy.
- Explicit — you choose which features to use. Nothing is included implicitly.
What’s in This Guide
Section titled “What’s in This Guide”- Elementary schemas — built-in schemas for primitives, literals, strings, numbers, dates, and template literals.
- Composite schemas — combine elementary schemas into structs (objects), tuples, arrays, records, and unions.
- Validation — add runtime checks (filters) to constrain values, report multiple errors, and define custom rules.
- Constructors — create validated values at runtime, with support for defaults, brands, and refinements.
- Transformations — convert values between types during decoding and encoding. Transformations are reusable objects you compose with schemas.
- Flipping — swap a schema’s decoding and encoding directions.
- Classes and opaque types — create distinct TypeScript types backed by structs, with optional methods and equality.
- Serialization — convert values to and from JSON, FormData, URLSearchParams, and XML using canonical codecs.
- Tooling — generate JSON Schemas, test data generators (Arbitraries), equivalence checks, optics, and JSON Patch differs from a single schema.
- Error handling — format validation errors for display, with hooks for internationalization.
- Middlewares — intercept decoding/encoding to provide fallbacks or inject services.
- Advanced topics — internal type model and type hierarchy (for library authors).
- Integrations — working examples for TanStack Form and Elysia.
- Migration from v3 — API mapping from Schema v3 to v4.
Defining Elementary Schemas
Section titled “Defining Elementary Schemas”Schema provides built-in schemas for all common TypeScript types. These schemas represent a single value — like a string or a number — and they are the building blocks you combine into more complex shapes.
Primitives
Section titled “Primitives”Use these schemas when a value should be exactly one of the basic JavaScript types.
import { Schema } from "effect"
// primitive typesSchema.StringSchema.NumberSchema.BigIntSchema.BooleanSchema.SymbolSchema.UndefinedSchema.NullSometimes you receive data that is not the right type yet — for example, a number that should become a string. You can build a schema that converts (coerces) values to the target type during decoding:
import { Getter, Parser, Schema } from "effect/schema"
// ┌─── Codec<string, unknown>// ▼const schema = Schema.Unknown.pipe( Schema.decodeTo(Schema.String, { decode: Getter.String(), encode: Getter.passthrough() }))
const parser = Parser.decodeUnknownSync(schema)
console.log(parser("tuna")) // => "tuna"console.log(parser(42)) // => "42"console.log(parser(true)) // => "true"console.log(parser(null)) // => "null"Literals
Section titled “Literals”A literal schema matches one exact value. Use it when a field must be a specific string, number, or other constant.
import { Schema } from "effect"
const tuna = Schema.Literal("tuna")const twelve = Schema.Literal(12)const twobig = Schema.Literal(2n)const tru = Schema.Literal(true)Symbol literals:
import { Schema } from "effect"
const terrific = Schema.UniqueSymbol(Symbol("terrific"))null, undefined, and void:
import { Schema } from "effect"
Schema.NullSchema.UndefinedSchema.VoidTo allow multiple literal values:
import { Schema } from "effect"
const schema = Schema.Literals(["red", "green", "blue"])To extract the set of allowed values from a literal schema:
import { Schema } from "effect"
const schema = Schema.Literals(["red", "green", "blue"])
// readonly ["red", "green", "blue"]schema.literals
// readonly [Schema.Literal<"red">, Schema.Literal<"green">, Schema.Literal<"blue">]schema.membersStrings
Section titled “Strings”You can add validation rules to a string schema. Each rule is applied with .check(...) and returns a new schema that enforces that constraint.
import { Schema } from "effect"
Schema.String.check(Schema.isMaxLength(5))Schema.String.check(Schema.isMinLength(5))Schema.String.check(Schema.isLengthBetween(5, 5))Schema.String.check(Schema.isPattern(/^[a-z]+$/))Schema.String.check(Schema.isStartsWith("aaa"))Schema.String.check(Schema.isEndsWith("zzz"))Schema.String.check(Schema.isIncludes("---"))Schema.String.check(Schema.isUppercased())Schema.String.check(Schema.isLowercased())To perform some simple string transforms:
import { Schema, SchemaTransformation } from "effect"
Schema.String.decode(SchemaTransformation.trim())Schema.String.decode(SchemaTransformation.toLowerCase())Schema.String.decode(SchemaTransformation.toUpperCase())String formats
Section titled “String formats”Schema includes built-in checks for common string formats.
import { Schema } from "effect"
Schema.String.check(Schema.isUUID())Schema.String.check(Schema.isBase64())Schema.String.check(Schema.isBase64Url())Numbers
Section titled “Numbers”import { Schema } from "effect"
Schema.Number // all numbersSchema.Finite // finite numbers (i.e. not +/-Infinity or NaN)You can add validation rules to a number schema. Each rule constrains the allowed range or value.
import { Schema } from "effect"
Schema.Number.check(Schema.isBetween({ minimum: 5, maximum: 10 }))Schema.Number.check(Schema.isGreaterThan(5))Schema.Number.check(Schema.isGreaterThanOrEqualTo(5))Schema.Number.check(Schema.isLessThan(5))Schema.Number.check(Schema.isLessThanOrEqualTo(5))Schema.Number.check(Schema.isMultipleOf(5))Integers
Section titled “Integers”To require that a number has no decimal part, use isInt(). For 32-bit integers specifically, use isInt32().
import { Schema } from "effect"
Schema.Number.check(Schema.isInt())Schema.Number.check(Schema.isInt32())BigInts
Section titled “BigInts”Schema does not ship pre-built BigInt validation factories (unlike numbers). Instead, you create your own using helper functions and a BigInt-compatible ordering. The example below shows how.
import { BigInt, Order, Schema } from "effect"
const options = { order: Order.BigInt }
const isBetween = Schema.makeIsBetween(options)const isGreaterThan = Schema.makeIsGreaterThan(options)const isGreaterThanOrEqualTo = Schema.makeIsGreaterThanOrEqualTo(options)const isLessThan = Schema.makeIsLessThan(options)const isLessThanOrEqualTo = Schema.makeIsLessThanOrEqualTo(options)const isMultipleOf = Schema.makeIsMultipleOf({ remainder: BigInt.remainder, zero: 0n})
const isPositive = isGreaterThan(0n)const isNonNegative = isGreaterThanOrEqualTo(0n)const isNegative = isLessThan(0n)const isNonPositive = isLessThanOrEqualTo(0n)
Schema.BigInt.check(isBetween({ minimum: 5n, maximum: 10n }))Schema.BigInt.check(isGreaterThan(5n))Schema.BigInt.check(isGreaterThanOrEqualTo(5n))Schema.BigInt.check(isLessThan(5n))Schema.BigInt.check(isLessThanOrEqualTo(5n))Schema.BigInt.check(isMultipleOf(5n))Schema.BigInt.check(isPositive)Schema.BigInt.check(isNonNegative)Schema.BigInt.check(isNegative)Schema.BigInt.check(isNonPositive)The Schema.Date schema matches Date objects (even invalid dates).
If you want to validate only valid dates, use Schema.DateValid instead.
Template literals
Section titled “Template literals”You can use Schema.TemplateLiteral to define structured string patterns made of multiple parts. Each part can be a literal or a schema, and additional constraints (such as isMinLength or isMaxLength) can be applied to individual parts.
Template literal matching is based on the semantics of each part rather than only a generated regular expression. Checks on string, number, and bigint schema parts are applied while matching each segment.
Example (Constraining parts of an email-like string)
import { Schema } from "effect"
// Construct a template literal schema for values like `${string}@${string}`// Apply constraints to both sides of the "@" symbolconst email = Schema.TemplateLiteral([ // Left part: must be a non-empty string Schema.String.check(Schema.isMinLength(1)),
// Separator "@",
// Right part: must be a string with a maximum length of 64 Schema.String.check(Schema.isMaxLength(64))])
// The inferred type is `${string}@${string}`export type Type = typeof email.Type
console.log(String(Schema.decodeUnknownExit(email)("a@b.com")))/*Success("a@b.com")*/
console.log(String(Schema.decodeUnknownExit(email)("@b.com")))/*Failure(Cause([Fail(SchemaError(Expected a string matching template literal parts, got "@b.com"))]))*/Template literal parser
Section titled “Template literal parser”If you want to extract the parts of a string that match a template, you can use Schema.TemplateLiteralParser. This allows you to parse the input into its individual components rather than treat it as a single string.
Example (Parsing a template literal into components)
import { Schema } from "effect"
const schema = Schema.TemplateLiteralParser([ Schema.String.check(Schema.isMinLength(2)), ":", Schema.Int])
// The inferred type is `readonly [string, ":", number]`export type Type = typeof schema.Type
console.log(String(Schema.decodeUnknownExit(schema)("aa:1")))// Success(["aa",":",1])
console.log(String(Schema.decodeUnknownExit(schema)("a:1")))// Failure(Cause([Fail(SchemaError(Expected a value with a length of at least 2, got "a"// at [0]))]))
console.log(String(Schema.decodeUnknownExit(schema)("aa:1.2")))// Failure(Cause([Fail(SchemaError(Expected an integer, got 1.2// at [2]))]))Defining Composite Schemas
Section titled “Defining Composite Schemas”Once you have elementary schemas, you can combine them into composite schemas that describe objects, arrays, tuples, key-value maps, and unions.
Structs
Section titled “Structs”A struct schema describes a JavaScript object with a known set of keys. Each key maps to a schema that validates and types its value.
Optional and Mutable Keys
Section titled “Optional and Mutable Keys”By default, every key in a struct is required and readonly. Use Schema.optionalKey to make a key optional (the key can be absent from the object), and Schema.mutableKey to make it writable.
You can mark struct properties as optional or mutable using Schema.optionalKey and Schema.mutableKey.
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String, b: Schema.optionalKey(Schema.String), c: Schema.mutableKey(Schema.String), d: Schema.optionalKey(Schema.mutableKey(Schema.String))})
/*with "exactOptionalPropertyTypes": true
type Type = { readonly a: string; readonly b?: string; c: string; d?: string;}*/type Type = (typeof schema)["Type"]Optional Fields
Section titled “Optional Fields”There are several ways to represent optional properties, depending on whether you want undefined in the type, null in the type, or just a missing key. By combining Schema.optionalKey, Schema.optional, and Schema.NullOr, you can represent any variant.
import { Schema } from "effect"
export const schema = Schema.Struct({ // Exact Optional Property a: Schema.optionalKey(Schema.FiniteFromString), // Optional Property b: Schema.optional(Schema.FiniteFromString), // Exact Optional Property with Nullability c: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)), // Optional Property with Nullability d: Schema.optional(Schema.NullOr(Schema.FiniteFromString))})
/*type Encoded = { readonly a?: string; readonly b?: string | undefined; readonly c?: string | null; readonly d?: string | null | undefined;}*/type Encoded = typeof schema.Encoded
/*type Type = { readonly a?: number; readonly b?: number | undefined; readonly c?: number | null; readonly d?: number | null | undefined;}*/type Type = typeof schema.TypeOmitting Values When Transforming Optional Fields
Section titled “Omitting Values When Transforming Optional Fields”If an optional field arrives as undefined, you may want to omit it from the output entirely rather than keeping it.
import { Option, Predicate, Schema, SchemaGetter } from "effect"
export const schema = Schema.Struct({ a: Schema.optional(Schema.FiniteFromString).pipe( Schema.decodeTo(Schema.optionalKey(Schema.Number), { decode: SchemaGetter.transformOptional( Option.filter(Predicate.isNotUndefined) // omit undefined ), encode: SchemaGetter.passthrough() }) )})
/*type Encoded = { readonly a?: string | undefined;}*/type Encoded = typeof schema.Encoded
/*type Type = { readonly a?: number;}*/type Type = typeof schema.TypeRepresenting Optional Fields with never Type
Section titled “Representing Optional Fields with never Type”You can use Schema.Never inside an optional key to represent a field that should never have a value but may still appear as a key in the type.
import { Schema } from "effect"
export const schema = Schema.Struct({ a: Schema.optionalKey(Schema.Never)})
/*type Encoded = { readonly a?: never;}*/type Encoded = typeof schema.Encoded
/*type Type = { readonly a?: never;}*/type Type = typeof schema.TypeDecoding Defaults
Section titled “Decoding Defaults”You can assign default values to fields during decoding using:
| API | Encoded side | Default value type |
|---|---|---|
Schema.withDecodingDefaultKey |
key absent | Encoded |
Schema.withDecodingDefault |
key absent or undefined |
Encoded |
Schema.withDecodingDefaultTypeKey |
key absent | Type |
Schema.withDecodingDefaultType |
key absent or undefined |
Type |
The “Key” variants use optionalKey (the key may be absent but not undefined), while the non-“Key” variants use optional (the key may be absent or undefined).
The “Type” variants accept a default specified as a Type (decoded) value, which is useful when the schema has a transformation and you want to provide the default in the decoded representation.
Encoded-Side Defaults
Section titled “Encoded-Side Defaults”withDecodingDefaultKey and withDecodingDefault accept a default specified as an
Encoded value (before any decoding transformation). This is the most common
case and works well when the Encoded and Type representations are the same, or
when you already have the value in encoded form.
Example (Default as an Encoded value)
In FiniteFromString, the Encoded type is string and the Type is number.
The default "1" is a string (the Encoded type), which is then decoded to 1.
import { Effect, Schema } from "effect"
const schema = Schema.Struct({ // ┌─── "1" is a string (Encoded type) // ▼ a: Schema.FiniteFromString.pipe(Schema.withDecodingDefault(Effect.succeed("1")))})
// ┌─── { readonly a?: string | undefined; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({}))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: undefined }))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: "2" }))// Output: { a: 2 }Type-Side Defaults
Section titled “Type-Side Defaults”withDecodingDefaultTypeKey and withDecodingDefaultType accept a default
specified as a Type value (the decoded representation). This is useful when
the schema has a transformation and you want to provide the default directly as a
decoded value, bypassing the decoding step.
Example (Default as a Type value)
Here the default 1 is a number (the Type), not a string. It does not go
through the FiniteFromString decoding transformation.
import { Effect, Schema } from "effect"
const schema = Schema.Struct({ // ┌─── 1 is a number (Type) // ▼ a: Schema.FiniteFromString.pipe(Schema.withDecodingDefaultType(Effect.succeed(1)))})
// ┌─── { readonly a?: string | undefined; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({}))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: undefined }))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: "2" }))// Output: { a: 2 }Nested Decoding Defaults
Section titled “Nested Decoding Defaults”You can also apply decoding defaults within nested structures.
Example (Nested struct with defaults for missing or undefined fields)
import { Effect, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.FiniteFromString.pipe(Schema.withDecodingDefault(Effect.succeed("1"))) }).pipe(Schema.withDecodingDefault(Effect.succeed({})))})
/*type Encoded = { readonly a?: { readonly b?: string | undefined; } | undefined;}*/type Encoded = typeof schema.Encoded
/*type Type = { readonly a: { readonly b: number; };}*/type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({}))// Output: { a: { b: 1 } }
console.log(Schema.decodeUnknownSync(schema)({ a: undefined }))// Output: { a: { b: 1 } }
console.log(Schema.decodeUnknownSync(schema)({ a: {} }))// Output: { a: { b: 1 } }
console.log(Schema.decodeUnknownSync(schema)({ a: { b: undefined } }))// Output: { a: { b: 1 } }
console.log(Schema.decodeUnknownSync(schema)({ a: { b: "2" } }))// Output: { a: { b: 2 } }Manual Decoding Defaults
Section titled “Manual Decoding Defaults”If the defaulting logic is more specific than just handling undefined or missing values, you can use Schema.decodeTo to apply custom fallback rules.
This is useful when you need to account for values like null or other invalid states.
Example (Providing a fallback when value is null or missing)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ a: Schema.optionalKey(Schema.NullOr(Schema.String)).pipe( Schema.decodeTo(Schema.FiniteFromString, { decode: SchemaGetter.transformOptional((oe) => oe.pipe( // remove null values Option.filter(Predicate.isNotNull), // default to "1" if none Option.orElseSome(() => "1") ) ), encode: SchemaGetter.passthrough() }) )})
// ┌─── { readonly a?: string | null; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({}))// Output: { a: 1 }
// console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// throws
console.log(Schema.decodeUnknownSync(schema)({ a: null }))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: "2" }))// Output: { a: 2 }Example (Providing a fallback when value is null, undefined, or missing)
import { Option, Predicate, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ a: Schema.optional(Schema.NullOr(Schema.String)).pipe( Schema.decodeTo(Schema.FiniteFromString, { decode: SchemaGetter.transformOptional((oe) => oe.pipe( // remove null and undefined Option.filter(Predicate.isNotNullish), // default to "1" if none Option.orElseSome(() => "1") ) ), encode: SchemaGetter.passthrough() }) )})
// ┌─── { readonly a?: string | null | undefined; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: number; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({}))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: undefined }))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: null }))// Output: { a: 1 }
console.log(Schema.decodeUnknownSync(schema)({ a: "2" }))// Output: { a: 2 }Optional Fields as Options
Section titled “Optional Fields as Options”Effect’s Option type is a safer alternative to undefined for representing the presence or absence of a value. These helpers convert between optional struct fields and Option values.
Exact Optional Property
Section titled “Exact Optional Property”import { Option, Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.OptionFromOptionalKey(Schema.FiniteFromString)})
// ┌─── { readonly quantity?: string; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
// console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// throws
console.log(Schema.encodeSync(Product)({ quantity: Option.some(2) }))// Output: { quantity: "2" }
console.log(Schema.encodeSync(Product)({ quantity: Option.none() }))// Output: {}Optional Property
Section titled “Optional Property”import { Option, Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.OptionFromOptional(Schema.FiniteFromString)})
// ┌─── { readonly quantity?: string | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.encodeSync(Product)({ quantity: Option.some(2) }))// Output: { quantity: "2" }
console.log(Schema.encodeSync(Product)({ quantity: Option.none() }))// Output: {}Exact Optional Property with Nullability
Section titled “Exact Optional Property with Nullability”import { Option, Predicate, Schema, SchemaTransformation } from "effect"
const Product = Schema.Struct({ quantity: Schema.optionalKey(Schema.NullOr(Schema.FiniteFromString)).pipe( Schema.decodeTo( Schema.Option(Schema.Number), SchemaTransformation.transformOptional({ decode: (oe) => oe.pipe(Option.filter(Predicate.isNotNull), Option.some), encode: Option.flatten }) ) )})
// ┌─── { readonly quantity?: string | null; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 } }
// console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// throwsOptional Property with Nullability
Section titled “Optional Property with Nullability”import { Schema } from "effect"
const Product = Schema.Struct({ quantity: Schema.OptionFromOptionalNullOr(Schema.FiniteFromString)})
// ┌─── { readonly quantity?: string | null | undefined; }// ▼type Encoded = typeof Product.Encoded
// ┌─── { readonly quantity: Option<number>; }// ▼type Type = typeof Product.Type
console.log(Schema.decodeUnknownSync(Product)({}))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: undefined }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: null }))// Output: { quantity: { _id: 'Option', _tag: 'None' } }
console.log(Schema.decodeUnknownSync(Product)({ quantity: "2" }))// Output: { quantity: { _id: 'Option', _tag: 'Some', value: 2 }Key Annotations
Section titled “Key Annotations”You can annotate individual keys using the annotateKey method. This is useful for adding a description or customizing the error message shown when the key is missing.
Example (Annotating a required username field)
import { Schema } from "effect"
const schema = Schema.Struct({ username: Schema.String.annotateKey({ description: "The username used to log in", // Custom message shown if the key is missing messageMissingKey: "Username is required" })})
console.log(String(Schema.decodeUnknownExit(schema)({})))/*Failure(Cause([Fail(SchemaError: Username is required at ["username"])]))*/Unexpected Key Message
Section titled “Unexpected Key Message”You can annotate a struct with a custom message to use when a key is unexpected (when onExcessProperty is error).
Example (Annotating a struct with a custom message)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String}).annotate({ messageUnexpectedKey: "Custom message" })
console.log(String(Schema.decodeUnknownExit(schema)({ a: "a", b: "b" }, { onExcessProperty: "error" })))/*Failure(Cause([Fail(SchemaError: Custom message at ["b"])]))*/Preserve unexpected keys
Section titled “Preserve unexpected keys”You can preserve unexpected keys by setting onExcessProperty to preserve.
Example (Preserving unexpected keys)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String})
console.log(String(Schema.decodeUnknownExit(schema)({ a: "a", b: "b" }, { onExcessProperty: "preserve" })))/*Output:Success({"b":"b","a":"a"})*/Index Signatures
Section titled “Index Signatures”An index signature lets a struct accept any string key in addition to its fixed keys. Use Schema.StructWithRest to combine a struct with one or more record schemas.
Filters applied to either the struct or the record are preserved when combined.
Example (Combining fixed properties with an index signature)
import { Schema } from "effect"
// Define a schema with one fixed key "a" and any number of string keys mapping to numbersexport const schema = Schema.StructWithRest(Schema.Struct({ a: Schema.Number }), [ Schema.Record(Schema.String, Schema.Number)])
/*type Type = { readonly [x: string]: number; readonly a: number;}*/type Type = typeof schema.Type
/*type Encoded = { readonly [x: string]: number; readonly a: number;}*/type Encoded = typeof schema.EncodedIf you want the record part to be mutable, you can wrap it in Schema.mutable.
Example (Allowing dynamic keys to be mutable)
import { Schema } from "effect"
// Define a schema with one fixed key "a" and any number of string keys mapping to numbersexport const schema = Schema.StructWithRest(Schema.Struct({ a: Schema.Number }), [ Schema.Record(Schema.String, Schema.mutableKey(Schema.Number))])
/*type Type = { [x: string]: number; readonly a: number;}*/type Type = typeof schema.Type
/*type Encoded = { [x: string]: number; readonly a: number;}*/type Encoded = typeof schema.EncodedRenaming Encoded Keys
Section titled “Renaming Encoded Keys”Use Schema.encodeKeys to rename one or more keys only in the encoded representation of a struct.
Pass a mapping of { decodedKey: encodedKey }. During decoding, the schema expects the mapped encoded keys. During encoding, it produces those keys. Keys not in the mapping are left unchanged.
Unlike Struct.renameKeys, this does not rename the struct’s own field names. It only remaps keys at the encoding / decoding boundary.
Example (Using snake_case keys in the encoded form)
import { Schema } from "effect"
const schema = Schema.Struct({ userId: Schema.FiniteFromString, accountName: Schema.String}).pipe( Schema.encodeKeys({ userId: "user_id", accountName: "account_name" }))
console.log(Schema.decodeUnknownSync(schema)({ user_id: "1", account_name: "alice" }))// { userId: 1, accountName: "alice" }
console.log(Schema.encodeUnknownSync(schema)({ userId: 1, accountName: "alice" }))// { user_id: "1", account_name: "alice" }If you are building a struct from reused fields or Schema.fieldsAssign, apply Schema.encodeKeys after defining the full struct.
Reusing Fields
Section titled “Reusing Fields”Every Schema.Struct exposes a .fields property containing its field definitions. You can spread these fields into a new struct to reuse them, similar to how TypeScript interfaces use extends.
Example (Single inheritance)
import { Schema } from "effect"
const Timestamped = Schema.Struct({ createdAt: Schema.Date, updatedAt: Schema.Date})
const User = Schema.Struct({ ...Timestamped.fields, name: Schema.String, email: Schema.String})
const Post = Schema.Struct({ ...Timestamped.fields, title: Schema.String, body: Schema.String})Example (Multiple inheritance)
import { Schema } from "effect"
const Timestamped = Schema.Struct({ createdAt: Schema.Date, updatedAt: Schema.Date})
const SoftDeletable = Schema.Struct({ deletedAt: Schema.optionalKey(Schema.Date)})
const User = Schema.Struct({ ...Timestamped.fields, ...SoftDeletable.fields, name: Schema.String, email: Schema.String})Deriving Structs
Section titled “Deriving Structs”You can derive new struct schemas from existing ones — picking, omitting, renaming, or transforming individual fields — without rewriting the schema from scratch. The mapFields method on Schema.Struct accepts a function that transforms the struct’s fields and returns a new Schema.Struct based on the result.
Use Struct.pick to keep only a selected set of fields.
Example (Picking specific fields from a struct)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.String;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields(Struct.pick(["a"]))Use Struct.omit to remove specified fields from a struct.
Example (Omitting fields from a struct)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.String;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields(Struct.omit(["b"]))Use Struct.assign to add new fields to an existing struct.
Example (Adding fields to a struct)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.String; readonly b: Schema.Number; readonly c: Schema.Boolean;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields( Struct.assign({ c: Schema.Boolean }))
// or more succinctlyconst schema2 = Schema.Struct({ a: Schema.String, b: Schema.Number}).pipe(Schema.fieldsAssign({ c: Schema.Boolean }))If you want to preserve the checks of the original struct, you can pass { unsafePreserveChecks: true } to the map method.
Warning: This is an unsafe operation. Since mapFields transformations change the schema type, the original refinement functions may no longer be valid or safe to apply to the transformed schema. Only use this option if you have verified that your refinements remain correct after the transformation.
Example (Preserving checks when merging fields)
import { Schema, Struct } from "effect"
const original = Schema.Struct({ a: Schema.String, b: Schema.String}).check(Schema.makeFilter(({ a, b }) => a === b, { title: "a === b" }))
const schema = original.mapFields(Struct.assign({ c: Schema.String }), { unsafePreserveChecks: true})
console.log( String( Schema.decodeUnknownExit(schema)({ a: "a", b: "b", c: "c" }) ))// Failure(Cause([Fail(SchemaError: Expected a === b, got {"a":"a","b":"b","c":"c"})]))Mapping individual fields
Section titled “Mapping individual fields”Use Struct.evolve to transform the value schema of individual fields.
Example (Modifying the type of a single field)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.optionalKey<Schema.String>; readonly b: Schema.Number;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields( Struct.evolve({ a: (field) => Schema.optionalKey(field) }))Mapping all fields at once
Section titled “Mapping all fields at once”If you want to transform the value schema of multiple fields at once, you can use Struct.map.
Example (Making all fields optional)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.optionalKey<Schema.String>; readonly b: Schema.optionalKey<Schema.Number>; readonly c: Schema.optionalKey<Schema.Boolean>;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number, c: Schema.Boolean}).mapFields(Struct.map(Schema.optionalKey))Mapping a subset of fields at once
Section titled “Mapping a subset of fields at once”If you want to map a subset of elements, you can use Struct.mapPick or Struct.mapOmit.
Example (Making a subset of fields optional)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.optionalKey<Schema.String>; readonly b: Schema.Number; readonly c: Schema.optionalKey<Schema.Boolean>;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number, c: Schema.Boolean}).mapFields(Struct.mapPick(["a", "c"], Schema.optionalKey))Or if it’s more convenient, you can use Struct.mapOmit.
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly a: Schema.optionalKey<Schema.String>; readonly b: Schema.Number; readonly c: Schema.optionalKey<Schema.Boolean>;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number, c: Schema.Boolean}).mapFields(Struct.mapOmit(["b"], Schema.optionalKey))Mapping individual keys
Section titled “Mapping individual keys”Use Struct.evolveKeys to rename field keys while keeping the corresponding value schemas.
Example (Uppercasing keys in a struct)
import { String } from "effect"import { Schema } from "effect"import { Struct } from "effect/data"
/*const schema: Schema.Struct<{ readonly A: Schema.String; readonly b: Schema.Number;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields( Struct.evolveKeys({ a: (key) => String.toUpperCase(key) }))If you simply want to rename keys with static keys, you can use Struct.renameKeys.
Example (Renaming keys in a struct)
import { Schema, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly A: Schema.String; readonly b: Schema.Number;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields( Struct.renameKeys({ a: "A" }))Mapping individual entries
Section titled “Mapping individual entries”Use Struct.evolveEntries when you want to transform both the key and the value of specific fields.
Example (Transforming keys and value schemas)
import { Schema, String, Struct } from "effect"
/*const schema: Schema.Struct<{ readonly b: Schema.Number; readonly A: Schema.optionalKey<Schema.String>;}>*/const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).mapFields( Struct.evolveEntries({ a: (key, value) => [String.toUpperCase(key), Schema.optionalKey(value)] }))Opaque Structs
Section titled “Opaque Structs”The previous examples can be applied to opaque structs as well.
import { Schema, Struct } from "effect"
class A extends Schema.Opaque<A>()( Schema.Struct({ a: Schema.String, b: Schema.Number })) {}
/*const schema: Schema.Struct<{ readonly a: Schema.optionalKey<Schema.String>; readonly b: Schema.Number;}>*/const schema = A.mapFields( Struct.evolve({ a: (field) => Schema.optionalKey(field) }))Tagged Structs
Section titled “Tagged Structs”A tagged struct is a struct that includes a _tag field. This field is used to identify the specific variant of the object, which is especially useful when working with union types.
When using the make method, the _tag field is optional and will be added automatically. However, when decoding or encoding, the _tag field must be present in the input.
Example (Tagged struct as a shorthand for a struct with a _tag field)
import { Schema } from "effect"
// Defines a struct with a fixed `_tag` fieldconst tagged = Schema.TaggedStruct("A", { a: Schema.String})
// This is the same as writing:const equivalent = Schema.Struct({ _tag: Schema.tag("A"), a: Schema.String})Example (Accessing the literal value of the tag)
// The `_tag` field is a schema with a known literal valueconst literal = tagged.fields._tag.schema.literal// literal: "A"Tuples
Section titled “Tuples”A tuple schema describes a fixed-length array where each position has its own type. Use tuples when the order and count of elements matters — for example, a [string, number] pair.
Rest Elements
Section titled “Rest Elements”You can add rest elements to a tuple using Schema.TupleWithRest.
Example (Adding rest elements to a tuple)
import { Schema } from "effect"
export const schema = Schema.TupleWithRest(Schema.Tuple([Schema.FiniteFromString, Schema.String]), [ Schema.Boolean, Schema.String])
/*type Type = readonly [number, string, ...boolean[], string]*/type Type = typeof schema.Type
/*type Encoded = readonly [string, string, ...boolean[], string]*/type Encoded = typeof schema.EncodedElement Annotations
Section titled “Element Annotations”You can annotate elements using the annotateKey method.
Example (Annotating an element)
import { Schema } from "effect"
const schema = Schema.Tuple([ Schema.String.annotateKey({ description: "my element description", // a message to display when the element is missing messageMissingKey: "this element is required" })])
console.log(String(Schema.decodeUnknownExit(schema)([])))/*Failure(Cause([Fail(SchemaError: this element is required at [0])]))*/Deriving Tuples
Section titled “Deriving Tuples”You can map the elements of a tuple schema using the mapElements static method on Schema.Tuple. The mapElements static method accepts a function from Tuple.elements to new elements, and returns a new Schema.Tuple based on the result.
Use Tuple.pick to keep only a selected set of elements.
Example (Picking specific elements from a tuple)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [Schema.String, Schema.Boolean]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements(Tuple.pick([0, 2]))Use Tuple.omit to remove specified elements from a tuple.
Example (Omitting elements from a tuple)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [Schema.String, Schema.Boolean]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements(Tuple.omit([1]))Adding Elements
Section titled “Adding Elements”You can add elements to a tuple schema using the appendElement and appendElements APIs of the Tuple module.
Example (Adding elements to a tuple)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.String, Schema.Number, Schema.Boolean, Schema.String, Schema.Number]>*/const schema = Schema.Tuple([Schema.String, Schema.Number]) .mapElements(Tuple.appendElement(Schema.Boolean)) // adds a single element .mapElements(Tuple.appendElements([Schema.String, Schema.Number])) // adds multiple elementsMapping individual elements
Section titled “Mapping individual elements”You can evolve the elements of a tuple schema using the evolve API of the Tuple module
Example
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.NullOr<Schema.String>, Schema.Number, Schema.NullOr<Schema.Boolean>]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements( Tuple.evolve([ (v) => Schema.NullOr(v), undefined, // no change (v) => Schema.NullOr(v) ]))Mapping all elements at once
Section titled “Mapping all elements at once”You can map all elements of a tuple schema using the map API of the Tuple module.
Example (Making all elements nullable)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.NullOr<Schema.String>, Schema.NullOr<Schema.Number>, Schema.NullOr<Schema.Boolean>]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements(Tuple.map(Schema.NullOr))Mapping a subset of elements at once
Section titled “Mapping a subset of elements at once”If you want to map a subset of elements, you can use Tuple.mapPick or Tuple.mapOmit.
Example (Making a subset of elements nullable)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.NullOr<Schema.String>, Schema.Number, Schema.NullOr<Schema.Boolean>]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements( Tuple.mapPick([0, 2], Schema.NullOr))Or if it’s more convenient, you can use Tuple.mapOmit.
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.NullOr<Schema.String>, Schema.Number, Schema.NullOr<Schema.Boolean>]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements( Tuple.mapOmit([1], Schema.NullOr))Renaming Indices
Section titled “Renaming Indices”You can rename the indices of a tuple schema using the renameIndices API of the Tuple module.
Example (Partial index mapping)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.Number, Schema.String, Schema.Boolean]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements( Tuple.renameIndices(["1", "0"]) // flip the first and second elements)Example (Full index mapping)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Tuple<readonly [ Schema.Boolean, Schema.Number, Schema.String]>*/const schema = Schema.Tuple([Schema.String, Schema.Number, Schema.Boolean]).mapElements( Tuple.renameIndices([ "2", // last element becomes first "1", // second element keeps its index "0" // first element becomes third ]))Arrays
Section titled “Arrays”An array schema describes a variable-length list where every element shares the same type.
Unique Arrays
Section titled “Unique Arrays”You can deduplicate arrays using Schema.UniqueArray.
Internally, Schema.UniqueArray uses Schema.Array and adds a check based on Schema.isUnique using ToEquivalence.make(item) for the equivalence.
import { Schema } from "effect"
const schema = Schema.UniqueArray(Schema.String)
console.log(String(Schema.decodeUnknownExit(schema)(["a", "b", "a"])))// Failure(Cause([Fail(SchemaError: Expected an array with unique items, got ["a","b","a"])]))Records
Section titled “Records”A record schema describes an object whose keys are dynamic (not known ahead of time). The key schema selects which own properties belong to the record, and the value schema validates the selected property values.
Properties that are not selected by the key schema are ignored by that record. For example, Schema.Record(Schema.String.check(Schema.isPattern(/^a/)), Schema.Number) decodes only string keys that start with "a".
Key Transformations
Section titled “Key Transformations”Schema.Record supports transforming keys during decoding and encoding. This can be useful when working with different naming conventions.
When a key schema has a transformation, dynamic property selection is based on the encoded property names. The selected keys are then decoded using the key schema.
Example (Transforming snake_case keys to camelCase)
import { Schema, SchemaTransformation } from "effect"
const SnakeToCamel = Schema.String.pipe(Schema.decode(SchemaTransformation.snakeToCamel()))
const schema = Schema.Record(SnakeToCamel, Schema.Number)
console.log(Schema.decodeUnknownSync(schema)({ a_b: 1, c_d: 2 }))// { aB: 1, cD: 2 }By default, if a transformation results in duplicate keys, the last value wins.
Example (Merging transformed keys by keeping the last one)
import { Schema, SchemaTransformation } from "effect"
const SnakeToCamel = Schema.String.pipe(Schema.decode(SchemaTransformation.snakeToCamel()))
const schema = Schema.Record(SnakeToCamel, Schema.Number)
console.log(Schema.decodeUnknownSync(schema)({ a_b: 1, aB: 2 }))// { aB: 2 }You can customize how key conflicts are resolved by providing a combine function.
Example (Combining values for conflicting keys)
import { Schema, SchemaTransformation } from "effect"
const SnakeToCamel = Schema.String.pipe(Schema.decode(SchemaTransformation.snakeToCamel()))
const schema = Schema.Record(SnakeToCamel, Schema.Number, { keyValueCombiner: { decode: { // When decoding, combine values of conflicting keys by summing them combine: ([_, v1], [k2, v2]) => [k2, v1 + v2] // you can pass a Semigroup to combine keys }, encode: { // Same logic applied when encoding combine: ([_, v1], [k2, v2]) => [k2, v1 + v2] } }})
console.log(Schema.decodeUnknownSync(schema)({ a_b: 1, aB: 2 }))// { aB: 3 }
console.log(Schema.encodeUnknownSync(schema)({ a_b: 1, aB: 2 }))// { a_b: 3 }Number Keys
Section titled “Number Keys”Records with number keys are supported.
Example (Record with number keys)
import { Schema } from "effect"
const schema = Schema.Record(Schema.Int, Schema.String)
console.log(String(Schema.decodeUnknownExit(schema)({ 1: "a", 2: "b" })))// Success({"1":"a","2":"b"})
console.log(String(Schema.decodeUnknownExit(schema)({ 1.1: "ignored" })))// Success({})
console.log(String(Schema.decodeUnknownExit(schema)({ 1: null })))// Failure(Cause([Fail(SchemaError(Expected string, got null// at ["1"]))]))Mutability
Section titled “Mutability”By default, records are tagged as readonly. You can mark a record as mutable using Schema.mutableKey as you do with structs.
Example (Defining a mutable record)
import { Schema } from "effect"
export const schema = Schema.Record(Schema.String, Schema.mutableKey(Schema.Number))
/*type Type = { [x: string]: number;}*/type Type = typeof schema.Type
/*type Encoded = { [x: string]: number;}*/type Encoded = typeof schema.EncodedLiteral Structs
Section titled “Literal Structs”When you pass a union of string literals as the key schema to Schema.Record, you get a struct-like schema where each literal becomes a required key. This mirrors how TypeScript’s built-in Record type behaves.
Example (Creating a literal struct with fixed string keys)
import { Schema } from "effect"
const schema = Schema.Record(Schema.Literals(["a", "b"]), Schema.Number)
/*type Type = { readonly a: number; readonly b: number;}*/type Type = typeof schema.TypeMutable Keys
Section titled “Mutable Keys”By default, keys are readonly. To make them mutable, use Schema.mutableKey just as you would with a standard struct.
Example (Literal struct with mutable keys)
import { Schema } from "effect"
const schema = Schema.Record(Schema.Literals(["a", "b"]), Schema.mutableKey(Schema.Number))
/*type Type = { a: number; b: number;}*/type Type = typeof schema.TypeOptional Keys
Section titled “Optional Keys”You can make the keys optional by wrapping the value schema with Schema.optional.
Example (Literal struct with optional keys)
import { Schema } from "effect"
const schema = Schema.Record(Schema.Literals(["a", "b"]), Schema.optional(Schema.Number))
/*type Type = { readonly a?: number; readonly b?: number;}*/type Type = typeof schema.TypeUnions
Section titled “Unions”A union schema accepts a value if it matches any one of its members. Unions are useful when a field can hold more than one type — for example, a value that is either a string or a number.
By default, unions are inclusive: a value is accepted if it matches any of the union’s members.
The members are checked in order, and the first one that matches is used.
Excluding Incompatible Members
Section titled “Excluding Incompatible Members”If a union member is not compatible with the input, it is automatically excluded during validation.
Example (Excluding incompatible members from the union)
import { Schema } from "effect"
const schema = Schema.Union([Schema.NonEmptyString, Schema.Number])
console.log(String(Schema.decodeUnknownExit(schema)("")))// Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 1, got "")]))If none of the union members match the input, the union fails with a message at the top level.
Example (All members excluded)
import { Schema } from "effect"
const schema = Schema.Union([Schema.NonEmptyString, Schema.Number])
console.log(String(Schema.decodeUnknownExit(schema)(null)))// Failure(Cause([Fail(SchemaError: Expected string | number, got null)]))This behavior is especially helpful when working with literal values. Instead of producing a separate error for each literal (as in version 3), the schema reports a single, clear message.
Example (Validating against a set of literals)
import { Schema } from "effect"
const schema = Schema.Literals(["a", "b"])
console.log(String(Schema.decodeUnknownExit(schema)(null)))// Failure(Cause([Fail(SchemaError: Expected "a" | "b", got null)]))Exclusive Unions
Section titled “Exclusive Unions”You can create an exclusive union, where the union matches if exactly one member matches, by passing the { mode: "oneOf" } option.
Example (Exclusive Union)
import { Schema } from "effect"
const schema = Schema.Union([Schema.Struct({ a: Schema.String }), Schema.Struct({ b: Schema.Number })], { mode: "oneOf"})
console.log(String(Schema.decodeUnknownExit(schema)({ a: "a", b: 1 })))// Failure(Cause([Fail(SchemaError: Expected exactly one member to match the input {"a":"a","b":1})]))Deriving Unions
Section titled “Deriving Unions”You can map the members of a union schema using the mapMembers static method on Schema.Union. The mapMembers static method accepts a function from Union.members to new members, and returns a new Schema.Union based on the result.
Adding Members
Section titled “Adding Members”You can add members to a union schema using the appendElement and appendElements APIs of the Tuple module.
Example (Adding members to a union)
import { Schema, Tuple } from "effect"
/*const schema: Schema.Union<readonly [ Schema.String, Schema.Number, Schema.Boolean, Schema.String, Schema.Number]>*/const schema = Schema.Union([Schema.String, Schema.Number]) .mapMembers(Tuple.appendElement(Schema.Boolean)) // adds a single member .mapMembers(Tuple.appendElements([Schema.String, Schema.Number])) // adds multiple membersMapping individual members
Section titled “Mapping individual members”You can evolve the members of a union schema using the evolve API of the Tuple module
Example
import { Schema, Tuple } from "effect"
/*const schema: Schema.Union<readonly [ Schema.Array$<Schema.String>, Schema.Number, Schema.Array$<Schema.Boolean>]>*/const schema = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]).mapMembers( Tuple.evolve([ (v) => Schema.Array(v), undefined, // no change (v) => Schema.Array(v) ]))Mapping all members at once
Section titled “Mapping all members at once”You can map all members of a union schema using the map API of the Tuple module.
Example
import { Schema, Tuple } from "effect"
/*const schema: Schema.Union<readonly [ Schema.Array$<Schema.String>, Schema.Array$<Schema.Number>, Schema.Array$<Schema.Boolean>]>*/const schema = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]).mapMembers(Tuple.map(Schema.Array))Union of Literals
Section titled “Union of Literals”You can create a union of literals using Schema.Literals.
import { Schema } from "effect"
const schema = Schema.Literals(["red", "green", "blue"])Deriving new literals
Section titled “Deriving new literals”You can map the members of a Schema.Literals schema using the mapMembers method. The mapMembers method accepts a function from Literals.members to new members, and returns a new Schema.Union based on the result.
import { Schema, Tuple } from "effect"
const schema = Schema.Literals(["red", "green", "blue"]).mapMembers( Tuple.evolve([ (a) => Schema.Struct({ _tag: a, a: Schema.String }), (b) => Schema.Struct({ _tag: b, b: Schema.Number }), (c) => Schema.Struct({ _tag: c, c: Schema.Boolean }) ]))
/*type Type = { readonly _tag: "red"; readonly a: string;} | { readonly _tag: "green"; readonly b: number;} | { readonly _tag: "blue"; readonly c: boolean;}*/type Type = (typeof schema)["Type"]Tagged Unions
Section titled “Tagged Unions”You can define a tagged union using the Schema.TaggedUnion helper. This is useful when combining multiple tagged structs into a union.
Example (Defining a tagged union with Schema.TaggedUnion)
import { Schema } from "effect"
// Create a union of two tagged structsconst schema = Schema.TaggedUnion({ A: { a: Schema.String }, B: { b: Schema.Finite }})This is equivalent to writing:
const schema = Schema.Union([ Schema.TaggedStruct("A", { a: Schema.String }), Schema.TaggedStruct("B", { b: Schema.Finite })])The result is a tagged union schema with built-in helpers based on the tag values. See the next section for more details.
Augmenting Tagged Unions
Section titled “Augmenting Tagged Unions”The asTaggedUnion function enhances a tagged union schema by adding helper methods for working with its members.
You need to specify the name of the tag field used to differentiate between variants.
Example (Adding tag-based helpers to a union)
import { Schema } from "effect"
const original = Schema.Union([ Schema.Struct({ type: Schema.tag("A"), a: Schema.String }), Schema.Struct({ type: Schema.tag("B"), b: Schema.Finite }), Schema.Struct({ type: Schema.tag("C"), c: Schema.Boolean })])
// Enrich the union with tag-based utilitiesconst tagged = original.pipe(Schema.toTaggedUnion("type"))This helper has some advantages over a dedicated constructor:
- It does not require changes to the original schema, just call a helper.
- You can apply it to schemas from external sources.
- You can choose among multiple possible tag fields if present.
- It supports unions that include nested unions.
Note. If the tag is the standard _tag field, you can use Schema.TaggedUnion instead.
Accessing Members by Tag
Section titled “Accessing Members by Tag”The cases property gives direct access to each member schema of the union.
Example (Getting a member schema from a tagged union)
const A = tagged.cases.Aconst B = tagged.cases.Bconst C = tagged.cases.CChecking Membership in a Subset of Tags
Section titled “Checking Membership in a Subset of Tags”The isAnyOf method lets you check if a value belongs to a selected subset of tags.
Example (Checking membership in a subset of union tags)
console.log(tagged.isAnyOf(["A", "B"])({ type: "A", a: "a" })) // trueconsole.log(tagged.isAnyOf(["A", "B"])({ type: "B", b: 1 })) // true
console.log(tagged.isAnyOf(["A", "B"])({ type: "C", c: true })) // falseType Guards
Section titled “Type Guards”The guards property provides a type guard for each tag.
Example (Using type guards for tagged members)
console.log(tagged.guards.A({ type: "A", a: "a" })) // trueconsole.log(tagged.guards.B({ type: "B", b: 1 })) // true
console.log(tagged.guards.A({ type: "B", b: 1 })) // falseMatching on a Tag
Section titled “Matching on a Tag”You can define a matcher function using the match method. This is a concise way to handle each variant of the union.
Example (Handling union members with match)
const matcher = tagged.match({ A: (a) => `This is an A: ${a.a}`, B: (b) => `This is a B: ${b.b}`, C: (c) => `This is a C: ${c.c}`})
console.log(matcher({ type: "A", a: "a" })) // This is an A: aconsole.log(matcher({ type: "B", b: 1 })) // This is a B: 1console.log(matcher({ type: "C", c: true })) // This is a C: trueRecursive Schemas
Section titled “Recursive Schemas”Use Schema.suspend when a schema needs to refer to itself (or to another schema that eventually refers back). suspend wraps a thunk, so the recursive reference is resolved lazily during decode / encode instead of eagerly during declaration.
Example (Recursive Struct with Same Encoded and Type)
import { Schema } from "effect"
interface Category { readonly name: string readonly children: ReadonlyArray<Category>}
const Category: Schema.Codec<Category> = Schema.Struct({ name: Schema.String, children: Schema.Array(Schema.suspend((): Schema.Codec<Category> => Category))})The explicit Schema.Codec<Category> annotation is important in recursive declarations because Category is referenced inside its own initializer. Without the annotation, TypeScript often cannot stabilize the self-referential type and falls back to an implicit any style error.
Example (Recursive Struct with Different Encoded and Type)
import { Schema } from "effect"
interface Category { readonly name: number readonly children: ReadonlyArray<Category>}
interface CategoryEncoded { readonly name: string readonly children: ReadonlyArray<CategoryEncoded>}
const Category: Schema.Codec<Category, CategoryEncoded> = Schema.Struct({ name: Schema.FiniteFromString, children: Schema.Array(Schema.suspend((): Schema.Codec<Category, CategoryEncoded> => Category))})Here the encoded shape differs from the runtime shape (name is string when encoded, number after decoding), so both type parameters must be explicit: Schema.Codec<Category, CategoryEncoded>.
Using only Schema.Codec<Category> would force encoded and decoded types to be the same, which does not describe this schema.
Example (Recursive Union)
import { Schema } from "effect"
type U = A | B
interface A { readonly a: string readonly next: U}interface B { readonly b: number readonly next: U}
const URef = Schema.suspend((): Schema.Codec<U> => U)
const A: Schema.Codec<A> = Schema.Struct({ a: Schema.String, next: URef})
const B: Schema.Codec<B> = Schema.Struct({ b: Schema.Number, next: URef})
const U: Schema.Codec<U> = Schema.Union([A, B])URef factors the recursive edge (U -> U) into one shared Schema.suspend value. Reusing it across members avoids duplicating the lazy reference and makes the intent clear: every variant points back to the same union schema.
Declaring Custom Types
Section titled “Declaring Custom Types”When none of the built-in schema combinators fit your data type, use Schema.declare or Schema.declareConstructor.
Schema.declare (non-parametric types)
Section titled “Schema.declare (non-parametric types)”Schema.declare creates a schema from a type guard — a function that checks whether an unknown value is of a given type. This is useful when you have a type that doesn’t fit the built-in combinators (like Struct, Array, etc.) and you need to teach Schema how to recognize it.
Schema.declare<T>( is: (u: unknown) => u is T, annotations?: { expected?: string; toCodecJson?: ...; ... })The first argument is your type guard. Schema will call it on any input value: if it returns true, decoding succeeds; if false, decoding fails.
Example (Creating a schema for URL)
import { Schema } from "effect"
// The type guard tells Schema how to recognize a URL instanceconst URLSchema = Schema.declare( (u): u is URL => u instanceof URL)
console.log(String(Schema.decodeUnknownExit(URLSchema)(new URL("https://example.com"))))// Success(https://example.com/)
console.log(String(Schema.decodeUnknownExit(URLSchema)(null)))// Failure(Cause([Fail(SchemaError(Expected <Declaration>, got null))]))Tip: For simple
instanceofchecks, preferSchema.instanceOf(URL), it wrapsSchema.declarewith aninstanceofguard automatically.
Customizing the error message with expected
Section titled “Customizing the error message with expected”The default error message Expected <Declaration> is not very descriptive. Use the expected annotation (second argument) to provide a human-readable name for your type.
Example (Adding an expected annotation)
import { Schema } from "effect"
const URLSchema = Schema.declare( (u): u is URL => u instanceof URL, { expected: "URL" })
console.log(String(Schema.decodeUnknownExit(URLSchema)(null)))// Failure(Cause([Fail(SchemaError(Expected URL, got null))]))// ^^^// Now the error message shows "URL" instead of "<Declaration>"Adding JSON support with toCodecJson
Section titled “Adding JSON support with toCodecJson”Schema.toCodecJson derives a codec that can convert your type to and from JSON. By default, declared schemas have no JSON representation — encoding produces null:
import { Schema } from "effect"
const URLSchema = Schema.declare( (u): u is URL => u instanceof URL, { expected: "URL" })
// Derive a JSON codec from the schemaconst codec = Schema.toCodecJson(URLSchema)
// Encoding a URL produces null because Schema doesn't know// how to serialize a URL to JSON yetconsole.log(String(Schema.encodeUnknownExit(codec)(new URL("https://example.com"))))// Success(null)To fix this, provide a toCodecJson annotation. This annotation is a function that returns an AST.Link, a bridge that describes how to convert between your custom type and a JSON-friendly representation.
You build a Link using Schema.link<T>(), which takes two arguments:
- A JSON-side schema — the shape of the JSON value (e.g.
Schema.Stringfor a URL string) - A transformation — how to convert back and forth between your type and the JSON value
Example (Making URL JSON-serializable)
import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"
const URLSchema = Schema.declare( (u): u is URL => u instanceof URL, { expected: "URL", // Teach Schema how to convert URL <-> JSON toCodecJson: () => Schema.link<globalThis.URL>()( // The JSON representation is a plain string Schema.String, // How to convert between URL and string SchemaTransformation.transformOrFail<URL, string>({ // JSON string -> URL (may fail if the string is not a valid URL) decode: (s) => Effect.try({ try: () => new URL(s), catch: (e) => new SchemaIssue.InvalidValue(Option.some(s), { message: globalThis.String(e) }) }), // URL -> JSON string (always succeeds) encode: (url) => Effect.succeed(url.href) }) ) })
const codec = Schema.toCodecJson(URLSchema)
// Now encoding produces the URL's href stringconsole.log(String(Schema.encodeUnknownExit(codec)(new URL("https://example.com"))))// Success("https://example.com/")
// And decoding parses a string back into a URLconsole.log(String(Schema.decodeUnknownExit(codec)("https://example.com")))// Success(https://example.com/)Schema.declareConstructor (parametric types)
Section titled “Schema.declareConstructor (parametric types)”While Schema.declare works for fixed types like URL or File, some types are generic — they contain other types as parameters. Think of Array<A>, Option<A>, or a custom Box<A>. The schema for Box<number> is different from Box<string> because the inner value has a different type.
Schema.declareConstructor handles this by letting you define a schema factory: a function that takes schemas for the type parameters and returns a schema for the full type.
Important:
declareConstructoris for types where the container shape is the same on both sides: only the inner type parameter changes (e.g.Box<Encoded>toBox<Type>). If you need to convert a structurally different type into your declared type (e.g.TtoBox<T>), first declareBoxwithdeclareConstructor, then define a separate transformation schema to express the conversion.
How the two-step call works
Section titled “How the two-step call works”declareConstructor uses a curried (two-step) call pattern:
Schema.declareConstructor<Type, Encoded>()( typeParameters, // array of schemas, one per type parameter run, // factory that produces the parsing function annotations // optional metadata (same as Schema.declare))- Outer call
declareConstructor<Type, Encoded>()— fixes the TypeScript types.Typeis the decoded type,Encodedis the encoded type. - Inner call
(typeParameters, run, annotations)— provides the runtime behavior:typeParameters— an array of schemas, one for each type variable (e.g.[itemSchema]forBox<A>)run— a function that receives resolved codecs for those type parameters and returns a parsing function(input, ast, options) => Effect<T, Issue>annotations— optional metadata likeexpected,toCodecJson, etc.
The parsing function you return from run is responsible for:
- Checking that the input has the right shape (e.g. is an object with a
valueproperty) - Recursively decoding inner values using the provided codecs
- Returning an
Effectthat succeeds with the decoded value or fails with an issue
Example (A generic Box<A> container)
import { Effect, Option, Schema, SchemaIssue, SchemaParser } from "effect"
// 1. Define the typeinterface Box<A> { readonly value: A}
// 2. A type guard that checks the shape (ignoring the inner type)const isBox = (u: unknown): u is Box<unknown> => typeof u === "object" && u !== null && "value" in u
// 3. Create a schema factory: given a schema for A, return a schema for Box<A>const Box = <A extends Schema.Top>(item: A) => Schema.declareConstructor<Box<A["Type"]>, Box<A["Encoded"]>>()( // Pass the inner schema as a type parameter [item], // `run` receives the resolved codec for `item` ([itemCodec]) => // Return the parsing function (u, ast, options) => { // First, check the outer shape if (!isBox(u)) { return Effect.fail(new SchemaIssue.InvalidType(ast, Option.some(u))) } // Then, decode the inner value using the item codec return Effect.mapBothEager( SchemaParser.decodeUnknownEffect(itemCodec)(u.value, options), { onSuccess: (value) => ({ value }), // Wrap inner errors with a Pointer so the error path shows ["value"] onFailure: (issue) => new SchemaIssue.Pointer(["value"], issue) } ) } )
// Use it: Box<number> that decodes strings to finite numbersconst schema = Box(Schema.FiniteFromString)
console.log(String(Schema.decodeUnknownExit(schema)({ value: "1" })))// Success({ value: 1 })
console.log(String(Schema.decodeUnknownExit(schema)({ value: "a" })))// Failure(Cause([Fail(SchemaError(Expected a finite number, got NaN// at ["value"]))]))
declareConstructoraccepts the sameannotationsasdeclare— includingexpected(for custom error messages) andtoCodecJson(for JSON serialization). See theSchema.declaresection above for details on how to use them.
Validation
Section titled “Validation”After defining a schema’s shape, you can add validation rules called filters. Filters check runtime values against constraints like minimum length, numeric range, or custom predicates. Validation happens at runtime — Schema checks the actual value against the rules you define and reports any violations.
You can apply filters with the .check method or the Schema.check function.
Define custom filters with Schema.makeFilter.
Example (Custom filter that checks minimum length)
import { Schema } from "effect"
// Filter: the string must have at least 3 charactersconst schema = Schema.String.check(Schema.makeFilter((s) => s.length >= 3))
console.log(String(Schema.decodeUnknownExit(schema)("")))// Failure(Cause([Fail(SchemaError: Expected <filter>, got "")]))You can attach annotations and provide a custom error message when defining a filter.
Example (Filter with annotations and a custom message)
import { Schema } from "effect"
// Filter with a title, description, and custom error messageconst schema = Schema.String.check( Schema.makeFilter((s) => s.length >= 3 || `length must be >= 3, got ${s.length}`, { title: "length >= 3", description: "a string with at least 3 characters" }))
console.log(String(Schema.decodeUnknownExit(schema)("")))// Failure(Cause([Fail(SchemaError: length must be >= 3, got 0)]))Filter error messages and schema identifiers
Section titled “Filter error messages and schema identifiers”The default formatter chooses the error label from the level that failed:
- If the input does not match the base schema type, the formatter reports a
type-level failure. In that case, a schema
identifieris used as the expected label. - If the base type matches but a filter fails, the formatter reports a filter
failure. In that case, the filter’s
messageannotation is used first, then itsexpectedannotation, and finally<filter>if neither is provided.
An identifier does not name a failed filter. Use expected to name the
filter in the default formatter, or message to replace the filter failure
message completely.
Example (Schema identifier versus filter expected message)
import { Schema } from "effect"
const Username = Schema.NonEmptyString.annotate({ identifier: "Username" })
console.log(String(Schema.decodeUnknownExit(Username)(null)))// Failure(Cause([Fail(SchemaError: Expected Username, got null)]))
console.log(String(Schema.decodeUnknownExit(Username)("")))// Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 1, got "")]))Filter return shapes
Section titled “Filter return shapes”A filter predicate can return any of the shapes described by Schema.FilterOutput:
undefinedortrue— success.false— generic failure (no custom message).string— failure with the string used as the error message.SchemaIssue.Issue— a fully-formed issue, returned as-is (escape hatch forComposite,AnyOf, etc.).{ path, issue }— failure attached to a nested path.issuecan be astring(wrapped in anInvalidValue) or a fullSchemaIssue.Issue.ReadonlyArray<FilterIssue>— several failures reported together. Empty arrays are success; a single element is unwrapped; multiple entries are 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"])]))Preserving Schema Type After Filtering
Section titled “Preserving Schema Type After Filtering”Adding a filter does not change the schema’s type. You can still use all schema-specific methods (like .fields on a struct or .make) after calling .check(...).
Example (Chaining filters and annotations without losing type information)
import { Schema } from "effect"
// ┌─── Schema.String// ▼Schema.String
// ┌─── Schema.String// ▼const NonEmptyString = Schema.String.check(Schema.isNonEmpty())
// ┌─── Schema.String// ▼const schema = NonEmptyString.annotate({})Even after adding a filter and an annotation, the schema is still a Schema.String.
Example (Accessing struct fields after filtering)
import { Schema } from "effect"
// Define a struct and apply a (dummy) filterconst schema = Schema.Struct({ name: Schema.String, age: Schema.Number}).check(Schema.makeFilter(() => true))
// The `.fields` property is still availableconst fields = schema.fieldsFilters as First-Class
Section titled “Filters as First-Class”Filters are standalone values that you can define once and reuse across different schemas. The same filter (for example, Schema.isMinLength) works on strings, arrays, or any type with a compatible shape.
You can pass multiple filters to a single .check(...) call.
Example (Combining filters on a string)
import { Schema } from "effect"
const schema = Schema.String.check( Schema.isMinLength(3), // value must be at least 3 chars long Schema.isTrimmed() // no leading/trailing whitespace)
console.log(String(Schema.decodeUnknownExit(schema)(" a")))// Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 3, got " a")]))Example (Using isMinLength with an object that has length)
import { Schema } from "effect"
// Object must have a numeric `length` field that is >= 3const schema = Schema.Struct({ length: Schema.Number }).check(Schema.isMinLength(3))
console.log(String(Schema.decodeUnknownExit(schema)({ length: 2 })))// Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 3, got {"length":2}]))Example (Validating array length)
import { Schema } from "effect"
// Array must contain at least 3 stringsconst schema = Schema.Array(Schema.String).check(Schema.isMinLength(3))
console.log(String(Schema.decodeUnknownExit(schema)(["a", "b"])))// Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 3, got ["a","b"]]))Multiple Issues Reporting
Section titled “Multiple Issues Reporting”By default, when { errors: "all" } is passed, all filters are evaluated, even if one fails. This allows multiple issues to be reported at once.
Example (Collecting multiple validation issues)
import { Schema } from "effect"
const schema = Schema.String.check(Schema.isMinLength(3), Schema.isTrimmed())
console.log( String( Schema.decodeUnknownExit(schema)(" a", { errors: "all" }) ))/*Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 3, got " a"Expected a string with no leading or trailing whitespace, got " a")]))*/Aborting Validation
Section titled “Aborting Validation”If you want to stop validation as soon as a filter fails, you can call the abort method on the filter.
Example (Short-circuit on first failure)
import { Schema } from "effect"
const schema = Schema.String.check( Schema.isMinLength(3).abort(), // Stop on failure here Schema.isTrimmed() // This will not run if minLength fails)
console.log( String( Schema.decodeUnknownExit(schema)(" a", { errors: "all" }) ))// Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 3, got " a")]))Filter Groups
Section titled “Filter Groups”Group filters into a reusable unit with Schema.makeFilterGroup. This helps when the same set of checks appears in multiple places.
Example (Reusable group for 32-bit integers)
import { Schema } from "effect"
// ┌─── FilterGroup<number>// ▼const isInt32 = Schema.makeFilterGroup( [Schema.isInt(), Schema.isBetween({ minimum: -2147483648, maximum: 2147483647 })], { title: "isInt32", description: "a 32-bit integer" })Refinements
Section titled “Refinements”Use Schema.refine to refine a schema to a more specific type.
Example (Require at least two items in a string array)
import { Schema } from "effect"
// ┌─── refine<readonly [string, string, ...string[]], Schema.Array$<Schema.String>>// ▼const refined = Schema.Array(Schema.String).pipe( Schema.refine((arr): arr is readonly [string, string, ...Array<string>] => arr.length >= 2))Branding
Section titled “Branding”Use Schema.brand to add a brand to a schema.
Example (Brand a string as a UserId)
import { Schema } from "effect"
// ┌─── Schema.brand<Schema.String, "UserId">// ▼const branded = Schema.String.pipe(Schema.brand("UserId"))Structural Filters
Section titled “Structural Filters”Some filters check the structure of a value rather than its contents — for example, the number of items in an array or the number of keys in an object. These are called structural filters.
Structural filters are evaluated separately from item-level filters, which allows multiple issues to be reported when { errors: "all" } is used. Examples include:
isMinLengthorisMaxLengthon arraysisMinSizeorisMaxSizeon objects with asizepropertyisMinPropertiesorisMaxPropertieson objects- any constraint that applies to the “shape” of a value rather than to its nested values
These filters are evaluated separately from item-level filters and allow multiple issues to be reported when { errors: "all" } is used.
Example (Validating an array with item and structural constraints)
import { Schema } from "effect"
const schema = Schema.Struct({ tags: Schema.Array(Schema.String.check(Schema.isNonEmpty())).check( Schema.isMinLength(3) // structural filter )})
console.log(String(Schema.decodeUnknownExit(schema)({ tags: ["a", ""] }, { errors: "all" })))/*Failure(Cause([Fail(SchemaError: Expected a value with a length of at least 1, got "" at ["tags"][1]Expected a value with a length of at least 3, got ["a",""] at ["tags"])]))*/Effectful Filters
Section titled “Effectful Filters”Filters passed to .check(...) must be synchronous. When you need to call an API or use a service during validation, use an effectful filter instead. Effectful filters run inside an Effect, which means they can be asynchronous and access services.
Define an effectful filter with Getter.checkEffect as part of a transformation.
Example (Asynchronous validation of a numeric value)
import { Effect, Option, Result, Schema, SchemaGetter, SchemaIssue } from "effect"
// Simulated API call that fails when userId is 0const myapi = (userId: number) => Effect.gen(function*() { if (userId === 0) { return new Error("not found") } return { userId } }).pipe(Effect.delay(100))
const schema = Schema.Finite.pipe( Schema.decode({ decode: SchemaGetter.checkEffect((n) => Effect.gen(function*() { // Call the async API and wrap the result in a Result const user = yield* Effect.result(myapi(n))
// If the result is an error, return a SchemaIssue return Result.isFailure(user) ? new SchemaIssue.InvalidValue(Option.some(n), { title: "not found" }) : undefined // No issue, value is valid }) ), encode: SchemaGetter.passthrough() }))Filter Factories
Section titled “Filter Factories”A filter factory is a function that returns a new filter each time you call it, letting you parameterize the constraint (for example, “greater than X” for any value of X).
Example (Factory for a isGreaterThan filter on ordered values)
import { Order, Schema } from "effect"
// Create a filter factory for values greater than a given valueexport const makeGreaterThan = <T>(options: { readonly order: Order.Order<T> readonly annotate?: ((exclusiveMinimum: T) => Schema.Annotations.Filter) | undefined readonly format?: (value: T) => string | undefined}) => { const greaterThan = Order.isGreaterThan(options.order) const format = options.format ?? globalThis.String return (exclusiveMinimum: T, annotations?: Schema.Annotations.Filter) => { return Schema.makeFilter<T>((input) => greaterThan(input, exclusiveMinimum), { title: `greaterThan(${format(exclusiveMinimum)})`, description: `a value greater than ${format(exclusiveMinimum)}`, ...options.annotate?.(exclusiveMinimum), ...annotations }) }}Constructors
Section titled “Constructors”A constructor creates a value of the schema’s type, running all validations at the time of creation. If the value does not satisfy the schema, the constructor throws an error. Every schema exposes a make method for this purpose.
For an alternative that does not throw on schema validation failures, use Schema.makeOption (or SchemaParser.makeOption), which returns Option.Some on success and Option.None for schema issues. Non-schema failures, such as defects, still throw.
import { Schema, SchemaParser } from "effect"
const schema = Schema.Struct({ a: Schema.Number.check(Schema.isGreaterThan(0))})
console.log(schema.makeOption({ a: 1 }))// { _id: 'Option', _tag: 'Some', value: { a: 1 } }
console.log(schema.makeOption({ a: -1 }))// { _id: 'Option', _tag: 'None' }
// Equivalent standalone usage:const parse = SchemaParser.makeOption(schema)
console.log(parse({ a: 1 }))// { _id: 'Option', _tag: 'Some', value: { a: 1 } }Constructors in Composed Schemas
Section titled “Constructors in Composed Schemas”To support constructing values from composed schemas, make is now available on all schemas, including unions.
import { Schema } from "effect"
const schema = Schema.Union([Schema.Struct({ a: Schema.String }), Schema.Struct({ b: Schema.Number })])
schema.make({ a: "hello" })schema.make({ b: 1 })Branded Constructors
Section titled “Branded Constructors”Branding adds an invisible marker to a type so that values from different domains cannot be accidentally mixed — even when they have the same underlying shape (for example, both are string). For branded schemas, the default constructor accepts an unbranded input and returns a branded output.
import { Schema } from "effect"
const schema = Schema.String.pipe(Schema.brand<"a">())
// make(input: string, options?: Schema.MakeOptions): string & Brand<"a">schema.makeHowever, when a branded schema is part of a composite (such as a struct), you must pass a branded value.
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String.pipe(Schema.brand<"a">()), b: Schema.Number})
/*make(input: { readonly a: string & Brand<"a">; readonly b: number;}, options?: Schema.MakeOptions): { readonly a: string & Brand<"a">; readonly b: number;}*/schema.makeRefined Constructors
Section titled “Refined Constructors”For refined schemas, the constructor accepts the unrefined type and returns the refined one.
import { Option, Schema } from "effect"
const schema = Schema.Option(Schema.String).pipe(Schema.refine(Option.isSome))
// make(input: Option.Option<string>, options?: Schema.MakeOptions): Option.Some<string>schema.makeAs with branding, when used in a composite schema, the refined value must be provided.
import { Option, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Option(Schema.String).pipe(Schema.refine(Option.isSome)), b: Schema.Number})
/*make(input: { readonly a: Option.Some<string>; readonly b: number;}, options?: Schema.MakeOptions): { readonly a: Option.Some<string>; readonly b: number;}*/schema.makeDefault Values in Constructors
Section titled “Default Values in Constructors”You can define a default value for a field using Schema.withConstructorDefault. If no value is provided at runtime (either the key is missing or the value is undefined), the constructor uses this default.
Example (Providing a default number)
import { Effect, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(-1)))})
console.log(schema.make({ a: 5 }))// { a: 5 }
console.log(schema.make({}))// { a: -1 }The Effect passed to withConstructorDefault will be executed each time a default value is needed.
Example (Re-executing the default function)
import { Effect, Schema } from "effect"
let counter = 0
const schema = Schema.Struct({ a: Schema.Date.pipe(Schema.withConstructorDefault(Effect.sync(() => new Date(counter++))))})
console.log(schema.make({}))// { a: 1970-01-01T00:00:00.000Z }
console.log(schema.make({}))// { a: 1970-01-01T00:00:00.001Z }Nested Constructor Default Values
Section titled “Nested Constructor Default Values”Default values can be nested inside composed schemas. In this case, inner defaults are resolved first.
Example (Nested default values)
import { Effect, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Struct({ b: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(-1))) }).pipe(Schema.withConstructorDefault(Effect.succeed({})))})
console.log(schema.make({}))// { a: { b: -1 } }console.log(schema.make({ a: {} }))// { a: { b: -1 } }Effectful Defaults
Section titled “Effectful Defaults”Default values can also come from an Effect, for example, reading from a configuration service or performing an asynchronous operation. The environment must be never (no required services).
Example (Using an effect to provide a default)
import { Effect, Schema, SchemaParser } from "effect"
const schema = Schema.Struct({ a: Schema.Number.pipe( Schema.withConstructorDefault( Effect.gen(function*() { yield* Effect.sleep(100) return -1 }) ) )})
SchemaParser.makeEffect(schema)({}).pipe(Effect.runPromise).then(console.log)// { a: -1 }Example (Providing a default from an optional service)
import { Context, Effect, Option, Schema, SchemaParser } from "effect"
// Define a service that may provide a default valueclass ConstructorService extends Context.Service<ConstructorService, { defaultValue: Effect.Effect<number> }>()( "ConstructorService") {}
const schema = Schema.Struct({ a: Schema.Number.pipe( Schema.withConstructorDefault( Effect.gen(function*() { yield* Effect.sleep(100) const oservice = yield* Effect.serviceOption(ConstructorService) if (Option.isNone(oservice)) { return -1 } return yield* oservice.value.defaultValue }) ) )})
SchemaParser.makeEffect(schema)({}) .pipe( Effect.provideService(ConstructorService, ConstructorService.of({ defaultValue: Effect.succeed(0) })), Effect.runPromise ) .then(console.log, console.error)// { a: 0 }Transformations
Section titled “Transformations”Transformations convert values from one type to another during decoding or encoding. They are standalone, reusable objects you compose with schemas.
Transformations as First-Class
Section titled “Transformations as First-Class”In previous versions, transformations were directly embedded in schemas. In the current version, they are defined as independent values that can be reused across schemas.
Example (Previous approach: inline transformation)
const Trim = transform( String, Trimmed, // non re-usable transformation { decode: (i) => i.trim(), encode: identity }) {}This style made it difficult to reuse logic across different schemas.
Now, transformations like trim are declared once and reused wherever needed.
Example (The trim built-in transformation)
import { SchemaTransformation } from "effect"
// const t: Transformation<string, string, never, never>const t = SchemaTransformation.trim()You can apply a transformation to any compatible schema. In this example, trim is applied to a string schema using Schema.decode (more on this later).
Example (Applying trim to a string schema)
import { Schema, SchemaTransformation } from "effect"
const schema = Schema.String.pipe(Schema.decode(SchemaTransformation.trim()))
console.log(Schema.decodeUnknownSync(schema)(" 123"))// 123The Transformation Type
Section titled “The Transformation Type”A Transformation carries four type parameters:
Transformation<T, E, RD, RE>T: the decoded (output) typeE: the encoded (input) typeRD: the context used while decodingRE: the context used while encoding
A Transformation consists of two Getter functions:
decode: Getter<T, E, RD>— transforms a value during decodingencode: Getter<E, T, RE>— transforms a value during encoding
Each Getter receives an input and an optional context and returns either a value or an error. Getters can be composed to build more complex logic.
Example (Implementation of Transformation.trim)
/** * @category String transformations * @since 4.0.0 */export function trim(): Transformation<string, string> { return new Transformation(Getter.trim(), Getter.passthrough())}In this case:
- The
decodeprocess usesGetter.trim()to remove leading and trailing whitespace. - The
encodeprocess usesGetter.passthrough(), which returns the input as is.
Composing Transformations
Section titled “Composing Transformations”You can combine transformations using the .compose method. The resulting transformation applies the decode and encode logic of both transformations in sequence.
Example (Trim and lowercase a string)
import { Option, SchemaTransformation } from "effect"
// Compose two transformations: trim followed by toLowerCaseconst trimToLowerCase = SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase())
// Run the decode logic manually to inspect the resultconsole.log(trimToLowerCase.decode.run(Option.some(" Abc"), {}))/*{ _id: 'Exit', _tag: 'Success', value: { _id: 'Option', _tag: 'Some', value: 'abc' }}*/In this example:
- The
decodelogic appliesGetter.trim()followed byGetter.toLowerCase(), producing a string that is trimmed and lowercased. - The
encodelogic isGetter.passthrough(), which simply returns the input as-is.
Transforming One Schema into Another
Section titled “Transforming One Schema into Another”To define how one schema transforms into another, you can use:
Schema.decodeTo(and its inverseSchema.encodeTo)Schema.decode(and its inverseSchema.encode)
These functions let you attach transformations to schemas, defining how values should be converted during decoding or encoding.
decodeTo
Section titled “decodeTo”Use Schema.decodeTo when you want to transform a source schema into a different target schema.
You must provide:
- The target schema
- An optional transformation
If no transformation is provided, the operation is called “schema composition” (see below).
Example (Parsing a number from a string)
import { Schema, SchemaTransformation } from "effect"
const NumberFromString = // source schema: String Schema.String.pipe( Schema.decodeTo( Schema.Number, // target schema: Number SchemaTransformation.numberFromString // built-in transformation that coerce a string to a number (and back) ) )
console.log(Schema.decodeUnknownSync(NumberFromString)("123"))// 123console.log(Schema.decodeUnknownSync(NumberFromString)("a"))// NaNdecode
Section titled “decode”Use Schema.decode when the source and target schemas are the same and you only want to apply a transformation.
This is a shorter version of decodeTo.
Example (Trimming whitespace from a string)
import { Schema, SchemaTransformation } from "effect"
// Equivalent to decodeTo(Schema.String, Transformation.trim())const TrimmedString = Schema.String.pipe(Schema.decode(SchemaTransformation.trim()))Defining an Inline Transformation
Section titled “Defining an Inline Transformation”You can create a transformation directly using helpers from the SchemaTransformation module.
For example, SchemaTransformation.transform lets you define a simple transformation by providing decode and encode functions.
Example (Converting meters to kilometers and back)
import { Schema, SchemaTransformation } from "effect"
// Defines a transformation that converts meters (number) to kilometers (number)// 1000 meters -> 1 kilometer (decode)// 1 kilometer -> 1000 meters (encode)const Kilometers = Schema.Finite.pipe( Schema.decode( SchemaTransformation.transform({ decode: (meters) => meters / 1000, encode: (kilometers) => kilometers * 1000 }) ))You can define transformations that may fail during decoding or encoding using SchemaTransformation.transformOrFail.
This is useful when you need to validate input or enforce rules that may not always succeed.
Example (Converting a string URL into a URL object)
import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"
const URLFromString = Schema.String.pipe( Schema.decodeTo( Schema.instanceOf(URL), SchemaTransformation.transformOrFail({ decode: (s) => Effect.try({ try: () => new URL(s), catch: () => new Issue.InvalidValue(Option.some(s), { message: `Invalid URL string: ${s}` }) }), encode: (url) => Effect.succeed(url.href) }) ))Schema composition
Section titled “Schema composition”You can compose transformations, but you can also compose schemas with Schema.decodeTo.
Example (Converting meters to miles via kilometers)
import { Schema, SchemaTransformation } from "effect"
const KilometersFromMeters = Schema.Finite.pipe( Schema.decode( SchemaTransformation.transform({ decode: (meters) => meters / 1000, encode: (kilometers) => kilometers * 1000 }) ))
const MilesFromKilometers = Schema.Finite.pipe( Schema.decode( SchemaTransformation.transform({ decode: (kilometers) => kilometers * 0.621371, encode: (miles) => miles / 0.621371 }) ))
const MilesFromMeters = KilometersFromMeters.pipe(Schema.decodeTo(MilesFromKilometers))This approach does not require the source and target schemas to be type-compatible. If you need more control over type compatibility, you can use one of the Transformation.passthrough* helpers.
Passthrough Helpers
Section titled “Passthrough Helpers”The passthrough, passthroughSubtype, and passthroughSupertype helpers let you compose schemas by describing how their types relate.
passthrough
Section titled “passthrough”Use passthrough when the encoded output of the target schema matches the type of the source schema.
Example (When To.Encoded === From.Type)
import { Schema, SchemaTransformation } from "effect"
const From = Schema.Struct({ a: Schema.String})
const To = Schema.Struct({ a: Schema.FiniteFromString})
// To.Encoded (string) = From.Type (string)const schema = From.pipe(Schema.decodeTo(To, SchemaTransformation.passthrough()))passthroughSubtype
Section titled “passthroughSubtype”Use passthroughSubtype when the source type is a subtype of the target’s encoded output.
Example (When From.Type is a subtype of To.Encoded)
import { Schema, SchemaTransformation } from "effect"
const From = Schema.FiniteFromString
const To = Schema.UndefinedOr(Schema.Number)
// From.Type (number) extends To.Encoded (number | undefined)const schema = From.pipe(Schema.decodeTo(To, SchemaTransformation.passthroughSubtype()))passthroughSupertype
Section titled “passthroughSupertype”Use passthroughSupertype when the target’s encoded output is a subtype of the source type.
Example (When To.Encoded is a subtype of From.Type)
import { Schema, SchemaTransformation } from "effect"
const From = Schema.UndefinedOr(Schema.String)
const To = Schema.FiniteFromString
// To.Encoded (string) extends From.Type (string | undefined)const schema = From.pipe(Schema.decodeTo(To, SchemaTransformation.passthroughSupertype()))Turning off strict mode
Section titled “Turning off strict mode”Strict mode ensures that decoding and encoding fully match. You can disable it by passing { strict: false } to passthrough.
Example (Turning off strict mode)
import { Schema, SchemaTransformation } from "effect"
const From = Schema.String
const To = Schema.Number
const schema = From.pipe(Schema.decodeTo(To, SchemaTransformation.passthrough({ strict: false })))Managing Optional Keys
Section titled “Managing Optional Keys”You can control how optional values are handled during transformations using the SchemaTransformation.transformOptional helper.
This helper works with Option<E> and returns an Option<T>, where:
Eis the encoded typeTis the decoded type
This function is useful when dealing with optional values that may be present or missing during decoding or encoding.
If the input is Option.none(), it means the value is not provided.
If it is Option.some(value), then the transformation logic is applied to value.
You control the optionality of the output by returning an Option:
Option.none(): exclude the key from the outputOption.some(transformedValue): include the transformed value
Example (Optional string key transformed to Option<NonEmptyString>)
import { Option, Schema, SchemaTransformation } from "effect"
const OptionFromNonEmptyString = Schema.optionalKey(Schema.String).pipe( Schema.decodeTo( Schema.Option(Schema.NonEmptyString), SchemaTransformation.transformOptional({ // Convert empty strings to None, and non-empty strings to Some(value) decode: (oe) => Option.isSome(oe) && oe.value !== "" ? Option.some(Option.some(oe.value)) : Option.some(Option.none()),
// Flatten nested Options back to a single optional string encode: (ot) => Option.flatten(ot) }) ))
const schema = Schema.Struct({ foo: OptionFromNonEmptyString})
// Decoding examples
console.log(Schema.decodeUnknownSync(schema)({}))// Output: { foo: None }
console.log(Schema.decodeUnknownSync(schema)({ foo: "" }))// Output: { foo: None }
console.log(Schema.decodeUnknownSync(schema)({ foo: "hi" }))// Output: { foo: Some("hi") }
// Encoding examples
console.log(Schema.encodeSync(schema)({ foo: Option.none() }))// Output: {}
console.log(Schema.encodeSync(schema)({ foo: Option.some("hi") }))// Output: { foo: "hi" }Omitting a Key During Encoding
Section titled “Omitting a Key During Encoding”Use SchemaGetter.omit() to exclude a field from the encoded output. At runtime, omit() returns Option.none(), which tells the struct parser to skip writing that key.
For this to work, the encoded side must be marked as optional with Schema.optionalKey. Otherwise, producing None for a required field causes a MissingKey error.
Example (Field present when decoded, omitted when encoded)
import { Effect, Schema, SchemaGetter } from "effect"
const schema = Schema.Struct({ a: Schema.FiniteFromString, b: Schema.String.pipe( Schema.encodeTo(Schema.optionalKey(Schema.String), { decode: SchemaGetter.withDefault(Effect.succeed("default_value")), encode: SchemaGetter.omit() }) )})
// ┌─── { readonly a: string; readonly b?: string; }// ▼type Encoded = typeof schema.Encoded
// ┌─── { readonly a: number; readonly b: string; }// ▼type Type = typeof schema.Type
console.log(Schema.decodeUnknownSync(schema)({ a: "1", b: "value" }))// Output: { a: 1, b: "value" }
console.log(Schema.decodeUnknownSync(schema)({ a: "1" }))// Output: { a: 1, b: "default_value" }
console.log(Schema.encodeSync(schema)({ a: 1, b: "default_value" }))// Output: { a: "1" }For the common case of a discriminator tag that should be omitted during encoding, use Schema.tagDefaultOmit:
import { Schema } from "effect"
const schema = Schema.Struct({ _tag: Schema.tagDefaultOmit("MyTag"), a: Schema.FiniteFromString})
console.log(Schema.decodeUnknownSync(schema)({ a: "1" }))// Output: { a: 1, _tag: "MyTag" }
console.log(Schema.encodeSync(schema)({ a: 1, _tag: "MyTag" }))// Output: { a: "1" }Flipping Schemas
Section titled “Flipping Schemas”Flipping a schema swaps its decoding and encoding directions. If a schema decodes a string into a number, the flipped version decodes a number into a string. This is useful when you want to reuse an existing schema but invert its direction.
Example (Flipping a schema that parses a string into a number)
import { Schema } from "effect"
// Flips a schema that decodes a string into a number,// turning it into one that decodes a number into a string//// ┌─── flip<FiniteFromString>// ▼const StringFromFinite = Schema.flip(Schema.FiniteFromString)You can access the original schema using the .schema property:
Example (Accessing the original schema)
import { Schema } from "effect"
const StringFromFinite = Schema.flip(Schema.FiniteFromString)
// ┌─── FiniteFromString// ▼StringFromFinite.schemaFlipping a schema twice returns a schema with the same structure and behavior as the original:
Example (Double flipping restores the original schema)
import { Schema } from "effect"
// ┌─── FiniteFromString// ▼const schema = Schema.flip(Schema.flip(Schema.FiniteFromString))How it works
Section titled “How it works”All internal operations in the Schema AST are symmetrical. Encoding with a schema is equivalent to decoding with its flipped version:
// Encoding with a schema is the same as decoding with its flipped versionencode(schema) = decode(flip(schema))This symmetry ensures that flipping works consistently across all schema types.
Flipped constructors
Section titled “Flipped constructors”A flipped schema also includes a constructor. It builds values of the encoded type from the original schema.
Example (Using a flipped schema to construct an encoded value)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.FiniteFromString})
/*type Encoded = { readonly a: string;}*/type Encoded = (typeof schema)["Encoded"]
// make: { readonly a: string } ──▶ { readonly a: string }Schema.flip(schema).makeClasses and Opaque Types
Section titled “Classes and Opaque Types”Schema supports two kinds of nominal types: opaque structs for lightweight distinct types, and classes for full-featured types with methods and prototype-backed instances.
Opaque Structs
Section titled “Opaque Structs”Goal: opaque typing without changing runtime behavior.
Schema.Opaque lets you take an ordinary Schema.Struct and wrap it in a thin class shell whose only purpose is to create a distinct TypeScript type.
Internally the value is still the same plain struct schema.
Instance methods and custom constructors are not allowed in opaque structs (no new ...).
This is not enforced at the type level, but it may be enforced through a linter in the future.
How is this different from Schema.Class?
Section titled “How is this different from Schema.Class?”Schema.Class also wraps a Struct, but it turns the wrapper into a proper class:
- You can add instance methods, getters, setters, custom constructors.
- Instances compare structurally with
Equal.equals, but they do not implementEqual. - Instances carry the class prototype at runtime, so
instanceofchecks succeed and methods are callable.
Example (Creating an Opaque Struct)
import { Schema } from "effect"
class Person extends Schema.Opaque<Person>()( Schema.Struct({ name: Schema.String })) {}
// ┌─── Codec<Person, { readonly name: string; }, never, never>// ▼const codec = Schema.revealCodec(Person)
// const person: Personconst person = Person.make({ name: "John" })
console.log(person.name)// "John"
// The class itself holds the original schema and its metadataconsole.log(Person)// -> [Function: Person] Struct$
// { readonly name: Schema.String }Person.fields
/*const another: Schema.Struct<{ readonly name: typeof Person;}>*/const another = Schema.Struct({ name: Person }) // You can use the opaque type inside other schemas
/*type Type = { readonly name: Person;}*/type Type = (typeof another)["Type"]Opaque structs can be used just like regular structs, with no other changes needed.
Example (Retrieving Schema Fields)
import { Schema } from "effect"
// A function that takes a generic structconst getFields = <Fields extends Schema.Struct.Fields>(struct: Schema.Struct<Fields>) => struct.fields
class Person extends Schema.Opaque<Person>()( Schema.Struct({ name: Schema.String })) {}
/*const fields: { readonly name: Schema.String;}*/const fields = getFields(Person)Static methods
Section titled “Static methods”You can add static members to an opaque struct class to extend its behavior.
Example (Custom serializer via static method)
import { Schema } from "effect"
class Person extends Schema.Opaque<Person>()( Schema.Struct({ name: Schema.String, createdAt: Schema.Date })) { // Create a custom serializer using the class itself static readonly serializer = Schema.toCodecJson(this)}
console.log( Schema.encodeUnknownSync(Person)({ name: "John", createdAt: new Date() }))// { name: 'John', createdAt: 2025-05-02T13:49:29.926Z }
console.log( Schema.encodeUnknownSync(Person.serializer)({ name: "John", createdAt: new Date() }))// { name: 'John', createdAt: '2025-05-02T13:49:29.928Z' }Annotations and filters
Section titled “Annotations and filters”You can attach filters and annotations to the struct passed into Opaque.
Example (Applying a filter and title annotation)
import { Schema } from "effect"
class Person extends Schema.Opaque<Person>()( Schema.Struct({ name: Schema.String }).annotate({ identifier: "Person" })) {}
console.log(String(Schema.decodeUnknownExit(Person)(null)))// Failure(Cause([Fail(SchemaError: Expected Person, got null)]))When you call methods like annotate on an opaque struct, you get back the original struct, not a new class.
import { Schema } from "effect"
class Person extends Schema.Opaque<Person>()( Schema.Struct({ name: Schema.String })) {}
/*const S: Schema.Struct<{ readonly name: Schema.String;}>*/const S = Person.annotate({ title: "Person" }) // `annotate` returns the wrapped struct typeRecursive Opaque Structs
Section titled “Recursive Opaque Structs”Example (Recursive Opaque Struct with Same Encoded and Type)
import { Schema } from "effect"
export class Category extends Schema.Opaque<Category>()( Schema.Struct({ name: Schema.String, children: Schema.Array(Schema.suspend((): Schema.Codec<Category> => Category)) })) {}
/*type Encoded = { readonly children: readonly Category[]; readonly name: string;}*/export type Encoded = (typeof Category)["Encoded"]Example (Recursive Opaque Struct with Different Encoded and Type)
import { Schema } from "effect"
interface CategoryEncoded extends Schema.Codec.Encoded<typeof Category> {}
export class Category extends Schema.Opaque<Category>()( Schema.Struct({ name: Schema.FiniteFromString, children: Schema.Array(Schema.suspend((): Schema.Codec<Category, CategoryEncoded> => Category)) })) {}
/*type Encoded = { readonly children: readonly CategoryEncoded[]; readonly name: string;}*/export type Encoded = (typeof Category)["Encoded"]Example (Mutually Recursive Schemas)
import { Schema } from "effect"
class Expression extends Schema.Opaque<Expression>()( Schema.Struct({ type: Schema.Literal("expression"), value: Schema.Union([Schema.Number, Schema.suspend((): Schema.Codec<Operation> => Operation)]) })) {}
class Operation extends Schema.Opaque<Operation>()( Schema.Struct({ type: Schema.Literal("operation"), operator: Schema.Literals(["+", "-"]), left: Expression, right: Expression })) {}
/*type Encoded = { readonly type: "operation"; readonly operator: "+" | "-"; readonly left: { readonly type: "expression"; readonly value: number | Operation; }; readonly right: { readonly type: "expression"; readonly value: number | Operation; };}*/export type Encoded = (typeof Operation)["Encoded"]Branded Opaque Structs
Section titled “Branded Opaque Structs”You can brand an opaque struct using the Brand generic parameter.
Example (Branded Opaque Struct)
import { Schema } from "effect"
class A extends Schema.Opaque<A, { readonly brand: unique symbol }>()( Schema.Struct({ a: Schema.String })) {}class B extends Schema.Opaque<B, { readonly brand: unique symbol }>()( Schema.Struct({ a: Schema.String })) {}
const f = (a: A) => aconst g = (b: B) => b
f(A.make({ a: "a" })) // okg(B.make({ a: "a" })) // ok
f(B.make({ a: "a" })) // error: Argument of type 'B' is not assignable to parameter of type 'A'.g(A.make({ a: "a" })) // error: Argument of type 'A' is not assignable to parameter of type 'B'.Like with branded classes, you can use the Brand module to create branded opaque structs.
import { Schema } from "effect"import type { Brand } from "effect"
class A extends Schema.Opaque<A, Brand.Brand<"A">>()( Schema.Struct({ a: Schema.String })) {}class B extends Schema.Opaque<B, Brand.Brand<"B">>()( Schema.Struct({ a: Schema.String })) {}
const f = (a: A) => aconst g = (b: B) => b
f(A.make({ a: "a" })) // okg(B.make({ a: "a" })) // ok
f(B.make({ a: "a" })) // error: Argument of type 'B' is not assignable to parameter of type 'A'.g(A.make({ a: "a" })) // error: Argument of type 'A' is not assignable to parameter of type 'B'.Schema as a Class
Section titled “Schema as a Class”Schema.asClass turns any schema into a class that can be extended with extends. The resulting class inherits the full schema API (e.g. annotate) and supports static methods that reference this.
Unlike Schema.Opaque, it does not make the decoded type nominally distinct, and unlike Schema.Class, it does not create prototype-backed instances with methods or constructors. It is a lightweight way to attach custom static helpers to a schema.
Wrapping a Primitive Schema
Section titled “Wrapping a Primitive Schema”import { Schema } from "effect"
class MyString extends Schema.asClass(Schema.String) { static readonly decodeUnknownSync = Schema.decodeUnknownSync(this)}
console.log(MyString.decodeUnknownSync("a"))// "a"Wrapping a Struct Schema
Section titled “Wrapping a Struct Schema”import { Schema } from "effect"
class MyStruct extends Schema.asClass( Schema.Struct({ name: Schema.String })) { static readonly decodeUnknownSync = Schema.decodeUnknownSync(this)}
console.log(MyStruct.decodeUnknownSync({ name: "a" }))// { name: "a" }Subclassing
Section titled “Subclassing”You can extend an asClass class to layer on more static helpers:
import { Schema } from "effect"
class MyString extends Schema.asClass(Schema.FiniteFromString) { static readonly decodeUnknownSync = Schema.decodeUnknownSync(this)}
class MyString2 extends MyString { static readonly encodeSync = Schema.encodeSync(this)}
console.log(MyString2.decodeUnknownSync("1"))// 1console.log(MyString2.encodeSync(1))// "1"Classes
Section titled “Classes”Existing Classes
Section titled “Existing Classes”Validating the Constructor
Section titled “Validating the Constructor”Use Case: When you want to validate the constructor arguments of an existing class.
Example (Using a tuple to validate the constructor arguments)
import { Schema } from "effect"
const PersonConstructorArguments = Schema.Tuple([Schema.String, Schema.Finite])
// Existing classclass Person { constructor(readonly name: string, readonly age: number) { PersonConstructorArguments.make([name, age]) }}
try { new Person("John", NaN)} catch (error) { if (error instanceof Error) { console.log(error.message) }}/*Expected a finite number, got NaN at [1]*/Example (Inheritance)
import { Schema } from "effect"
const PersonConstructorArguments = Schema.Tuple([Schema.String, Schema.Finite])
class Person { constructor(readonly name: string, readonly age: number) { PersonConstructorArguments.make([name, age]) }}
const PersonWithEmailConstructorArguments = Schema.Tuple([Schema.String])
class PersonWithEmail extends Person { constructor(name: string, age: number, readonly email: string) { // Only validate the additional argument PersonWithEmailConstructorArguments.make([email]) super(name, age) }}Defining a Schema
Section titled “Defining a Schema”import { Schema, SchemaTransformation } from "effect"
class Person { constructor(readonly name: string, readonly age: number) {}}
const PersonSchema = Schema.instanceOf(Person, { title: "Person", // optional: default JSON serialization toCodecJson: () => Schema.link<Person>()( Schema.Tuple([Schema.String, Schema.Number]), SchemaTransformation.transform({ decode: (args) => new Person(...args), encode: (instance) => [instance.name, instance.age] as const }) )}) // optional: explicit encoding .pipe( Schema.encodeTo( Schema.Struct({ name: Schema.String, age: Schema.Number }), SchemaTransformation.transform({ decode: (args) => new Person(args.name, args.age), encode: (instance) => instance }) ) )Example (Inheritance)
import { Schema, SchemaTransformation } from "effect"
class Person { constructor(readonly name: string, readonly age: number) {}}
const PersonSchema = Schema.instanceOf(Person, { title: "Person", // optional: default JSON serialization toCodecJson: () => Schema.link<Person>()( Schema.Tuple([Schema.String, Schema.Number]), SchemaTransformation.transform({ decode: (args) => new Person(...args), encode: (instance) => [instance.name, instance.age] as const }) )}) // optional: explicit encoding .pipe( Schema.encodeTo( Schema.Struct({ name: Schema.String, age: Schema.Number }), SchemaTransformation.transform({ decode: (args) => new Person(args.name, args.age), encode: (instance) => instance }) ) )
class PersonWithEmail extends Person { constructor(name: string, age: number, readonly email: string) { super(name, age) }}
// const PersonWithEmailSchema = ...repeat the pattern above...Errors
Section titled “Errors”Example (Extending Data.Error)
import { Data, Effect, identity, Schema, SchemaTransformation, SchemaUtils } from "effect"
const Props = Schema.Struct({ message: Schema.String})
class Err extends Data.Error<typeof Props.Type> { constructor(props: typeof Props.Type) { super(Props.make(props)) }}
const program = Effect.gen(function*() { yield* new Err({ message: "Uh oh" })})
Effect.runPromiseExit(program).then((exit) => console.log(JSON.stringify(exit, null, 2)))/*{ "_id": "Exit", "_tag": "Failure", "cause": { "_id": "Cause", "failures": [ { "_tag": "Fail", "error": { "message": "Uh oh" } } ] }}*/
const transformation = SchemaTransformation.transform<Err, (typeof Props)["Type"]>({ decode: (props) => new Err(props), encode: identity})
const schema = Schema.instanceOf(Err, { title: "Err", serialization: { json: () => Schema.link<Err>()(Props, transformation) }}).pipe(Schema.encodeTo(Props, transformation))
// built-in helper?const builtIn = SchemaUtils.getNativeClassSchema(Err, { encoding: Props })Class API
Section titled “Class API”Example (Constructing and decoding a class)
import { Schema } from "effect"
// Define a class with a single string field "a"class A extends Schema.Class<A>("A")({ a: Schema.String}) { // Regular class fields are allowed readonly _a = 1}
console.log(new A({ a: "a" }))// A { a: 'a', _a: 1 }console.log(A.make({ a: "a" }))// A { a: 'a', _a: 1 }console.log(Schema.decodeUnknownSync(A)({ a: "a" }))// A { a: 'a', _a: 1 }Filters
Section titled “Filters”To attach a filter to the whole class, pass a Struct instead of a field record and call .check(...) on it.
Example (Validating a relationship between fields)
import { Schema } from "effect"
class A extends Schema.Class<A>("A")( Schema.Struct({ a: Schema.String, b: Schema.String }).check(Schema.makeFilter(({ a, b }) => a === b, { title: "a === b" }))) {}
try { new A({ a: "a", b: "b" })} catch (error: any) { console.log(error.message)}// Expected a === b, got {"a":"a","b":"b"}
try { Schema.decodeUnknownSync(A)({ a: "a", b: "b" })} catch (error: any) { console.log(error.message)}// Expected a === b, got {"a":"a","b":"b"}Branded Classes
Section titled “Branded Classes”Attach a brand to a class to avoid mixing values from different domains that share the same structure.
Example (Unique brands block assignment)
import { Schema } from "effect"
// Brand the class using a unique symbol type parameterclass A extends Schema.Class<A, { readonly brand: unique symbol }>("A")({ a: Schema.String}) {}
class B extends Schema.Class<B, { readonly brand: unique symbol }>("B")({ a: Schema.String}) {}
// Even though A and B have the same fields, their brands are different,// so they are not assignable to each other.
// @ts-expect-errorexport const a: A = B.make({ a: "a" })// @ts-expect-errorexport const b: B = A.make({ a: "a" })Example (Using the Brand module)
import type { Brand } from "effect"import { Schema } from "effect"
class A extends Schema.Class<A, Brand.Brand<"A">>("A")({ a: Schema.String}) {}
class B extends Schema.Class<B, Brand.Brand<"B">>("B")({ a: Schema.String}) {}
// Different named brands are still not assignable
// @ts-expect-errorexport const a: A = B.make({ a: "a" })// @ts-expect-errorexport const b: B = A.make({ a: "a" })Annotations
Section titled “Annotations”Attach metadata to a class schema. The metadata is stored as annotations on the schema AST and can be read at runtime.
Example (Attaching and reading annotations)
import { Schema } from "effect"
export class A extends Schema.Class<A>("A")( { a: Schema.String }, // Attach metadata (e.g., title) alongside the schema { title: "my title" }) {}
console.log(A.ast.annotations?.title)// "my title"extend
Section titled “extend”Use extend to create a subclass that adds fields to the base schema. Instance fields declared on the base class are also available on the subclass.
Example (Extending a class with new fields)
import { Schema } from "effect"
// Base class with one schema field ("a") and one regular class field ("_a")class A extends Schema.Class<A>("A")( Schema.Struct({ a: Schema.String })) { readonly _a = 1}
// Subclass adds a new schema field ("b") and its own regular field ("_b")class B extends A.extend<B>("B")({ b: Schema.Number}) { readonly _b = 2}
console.log(new B({ a: "a", b: 2 }))// B { a: 'a', _a: 1, _b: 2 }console.log(B.make({ a: "a", b: 2 }))// B { a: 'a', _a: 1, _b: 2 }console.log(Schema.decodeUnknownSync(B)({ a: "a", b: 2 }))// B { a: 'a', _a: 1, _b: 2 }extends and static members
Section titled “extends and static members”To keep static members from the base class, pass typeof Base as the second generic parameter when calling extend.
Example (Preserving static members on subclasses)
import { Schema } from "effect"
class A extends Schema.Class<A>("A")({ a: Schema.String}) { static readonly foo = "foo"}
class B extends A.extend<B, typeof A>("B")({ b: Schema.Number}) {}
console.log(B.foo)// "foo"Recursive Classes
Section titled “Recursive Classes”Use Schema.suspend to reference a class inside its own definition. This is common for tree-like data structures.
Example (Self-referential tree structure)
import { Schema } from "effect"
// A simple tree of categories where each node can have child categories.// Use Schema.suspend to refer to Category while it is being defined.export class Category extends Schema.Class<Category>("Category")( Schema.Struct({ name: Schema.String, children: Schema.Array(Schema.suspend((): Schema.Codec<Category> => Category)) })) {}
/*type Encoded = { readonly children: readonly Category[]; readonly name: string;}*/export type Encoded = (typeof Category)["Encoded"]Example (Recursive schema with different Encoded and Type)
import { Schema } from "effect"
// Define the encoded representation for Category separately.// This is useful when the Encoded type differs from the Type type.interface CategoryEncoded extends Schema.Codec.Encoded<typeof Category> {}
// The runtime type is Category; the encoded form is CategoryEncoded.// "name" is decoded from a string to a finite number to show that// Type and Encoded types can differ.export class Category extends Schema.Class<Category>("Category")( Schema.Struct({ name: Schema.FiniteFromString, children: Schema.Array(Schema.suspend((): Schema.Codec<Category, CategoryEncoded> => Category)) })) {}
/*type Encoded = { readonly children: readonly CategoryEncoded[]; readonly name: string;}*/export type Encoded = (typeof Category)["Encoded"]Example (Mutually recursive expression language)
import { Schema } from "effect"
class Expression extends Schema.Class<Expression>("Expression")( Schema.Struct({ type: Schema.Literal("expression"), value: Schema.Union([Schema.Number, Schema.suspend((): Schema.Codec<Operation> => Operation)]) })) {}
class Operation extends Schema.Class<Operation>("Operation")( Schema.Struct({ type: Schema.Literal("operation"), operator: Schema.Literals(["+", "-"]), left: Expression, right: Expression })) {}
/*type Encoded = { readonly type: "operation"; readonly operator: "+" | "-"; readonly left: { readonly type: "expression"; readonly value: number | Operation; }; readonly right: { readonly type: "expression"; readonly value: number | Operation; };}*/export type Encoded = (typeof Operation)["Encoded"]TaggedClass
Section titled “TaggedClass”TaggedClass is a convenience over Class that automatically adds a _tag field using Schema.tag. This is useful for discriminated unions where each variant needs a tag.
The tag value doubles as the identifier by default. Pass an explicit identifier as the first argument to override it.
Example (Basic tagged class)
import { Schema } from "effect"
class Person extends Schema.TaggedClass<Person>()("Person", { name: Schema.String}) {}
const mike = new Person({ name: "Mike" })console.log(mike)// Person { _tag: 'Person', name: 'Mike' }console.log(mike._tag)// "Person"Example (Custom identifier)
import { Schema } from "effect"
class Person extends Schema.TaggedClass<Person>("MyPerson")("Person", { name: Schema.String}) {}
console.log(Person.identifier)// "MyPerson"console.log(new Person({ name: "Mike" })._tag)// "Person"Example (Discriminated union)
import { Schema } from "effect"
class Cat extends Schema.TaggedClass<Cat>()("Cat", { lives: Schema.Number}) {}
class Dog extends Schema.TaggedClass<Dog>()("Dog", { wagsTail: Schema.Boolean}) {}
const Animal = Schema.Union([Cat, Dog])
console.log(Schema.decodeUnknownSync(Animal)({ _tag: "Cat", lives: 9 }))// Cat { _tag: 'Cat', lives: 9 }All features from Class are available: extend, annotate, check, branded classes, and recursive definitions.
ErrorClass
Section titled “ErrorClass”import { Schema } from "effect"
class E extends Schema.ErrorClass<E>("E")({ id: Schema.Number}) {}TaggedErrorClass
Section titled “TaggedErrorClass”TaggedErrorClass combines ErrorClass with an automatic _tag field, giving you a tagged error that can be caught with Effect.catchTag.
Like TaggedClass, the tag value doubles as the identifier by default, and you can pass an explicit identifier as the first argument to override it.
Example (Defining and catching a tagged error)
import { Effect, Schema } from "effect"
class HttpError extends Schema.TaggedErrorClass<HttpError>()("HttpError", { status: Schema.Number, message: Schema.String}) {}
const program = Effect.gen(function*() { yield* new HttpError({ status: 404, message: "Not found" })})
const recovered = program.pipe( Effect.catchTag("HttpError", (err) => Effect.succeed(`Caught: ${err.status} ${err.message}`)))Example (Multiple tagged errors in a union)
import { Effect, Schema } from "effect"
class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", { path: Schema.String}) {}
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", { reason: Schema.String}) {}
const program = Effect.gen(function*() { if (Math.random() < 0.5) { yield* new Unauthorized({ reason: "Unauthorized" }) } else { yield* new NotFound({ path: "/missing" }) }})
// Each error can be caught independently by its tagconst recovered = program.pipe( Effect.catchTags({ NotFound: (err) => Effect.succeed(`Not found: ${err.path}`), Unauthorized: (err) => Effect.succeed(`Unauthorized: ${err.reason}`) }))All features from ErrorClass are available: extend, annotate, and check.
Serialization
Section titled “Serialization”Serialization converts typed values into a format suitable for storage or transmission (such as JSON, FormData, or XML). Deserialization reverses the process, turning raw data back into typed values. Schema provides built-in support for several common formats.
JSON Support
Section titled “JSON Support”UnknownFromJsonString
Section titled “UnknownFromJsonString”A schema that decodes a JSON-encoded string into an unknown value.
This schema takes a string as input and attempts to parse it as JSON during decoding. If parsing succeeds, the result is passed along as an unknown value. If the string is not valid JSON, decoding fails.
When encoding, any value is converted back into a JSON string using JSON.stringify. If the value is not a valid JSON value, encoding fails.
Example
import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.UnknownFromJsonString)(`{"a":1,"b":2}`)// => { a: 1, b: 2 }fromJsonString
Section titled “fromJsonString”Returns a schema that decodes a JSON string and then decodes the parsed value using the given schema.
This is useful when working with JSON-encoded strings where the actual structure of the value is known and described by an existing schema.
The resulting schema first parses the input string as JSON, and then runs the provided schema on the parsed result.
Example
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Number })const schemaFromJsonString = Schema.fromJsonString(schema)
Schema.decodeUnknownSync(schemaFromJsonString)(`{"a":1,"b":2}`)// => { a: 1 }String Encoding Support
Section titled “String Encoding Support”Schema provides built-in schemas for common string encodings. Each one decodes an encoded string into a UTF-8 string (and encodes back). They can be composed with fromJsonString to decode structured data in a single pipeline.
StringFromBase64
Section titled “StringFromBase64”Decodes a Base64-encoded (RFC 4648) string into a UTF-8 string.
import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.StringFromBase64)("aGVsbG8=")// => "hello"Compose with fromJsonString to decode Base64-encoded JSON into a validated struct:
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Number })
// base64 string -> UTF-8 string -> parsed & validated structconst schemaFromBase64 = Schema.StringFromBase64.pipe( Schema.decodeTo(Schema.fromJsonString(schema)))StringFromBase64Url
Section titled “StringFromBase64Url”Like StringFromBase64, but uses the URL-safe Base64 alphabet (RFC 4648 section 5).
import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.StringFromBase64Url)("aGVsbG8")// => "hello"StringFromHex
Section titled “StringFromHex”Decodes a hex-encoded string into a UTF-8 string.
import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.StringFromHex)("68656c6c6f")// => "hello"StringFromUriComponent
Section titled “StringFromUriComponent”Decodes a URI-component-encoded string into a UTF-8 string. Useful for storing structured data in URL query parameters.
import { Schema } from "effect"
const PaginationSchema = Schema.Struct({ maxItemPerPage: Schema.Number, page: Schema.Number})
const UrlSchema = Schema.StringFromUriComponent.pipe( Schema.decodeTo(Schema.fromJsonString(PaginationSchema)))
console.log(Schema.encodeSync(UrlSchema)({ maxItemPerPage: 10, page: 1 }))// %7B%22maxItemPerPage%22%3A10%2C%22page%22%3A1%7DUint8Array variants
Section titled “Uint8Array variants”For binary data, use the Uint8Array variants instead:
Schema.Uint8ArrayFromBase64- decodes Base64 into aUint8Array.Schema.Uint8ArrayFromBase64Url- decodes URL-safe Base64 into aUint8Array.Schema.Uint8ArrayFromHex- decodes hex into aUint8Array.
Low-level transformations
Section titled “Low-level transformations”The SchemaTransformation module exposes the underlying transformations (stringFromBase64String, stringFromBase64UrlString, stringFromHexString, stringFromUriComponent). Prefer the built-in Schema.* schemas above unless you need to build a custom pipeline.
FormData Support
Section titled “FormData Support”Schema.fromFormData returns a schema that reads a FormData instance,
converts it into a tree record using bracket notation, and then decodes the
resulting structure using the provided schema.
The decoding process has two steps:
- Parse
FormDatainto a nested tree record. - Decode the parsed value with the given schema.
Example (Decoding a flat structure)
import { Schema } from "effect"
const schema = Schema.fromFormData( Schema.Struct({ a: Schema.String }))
const formData = new FormData()formData.append("a", "1")formData.append("b", "2")
console.log(String(Schema.decodeUnknownExit(schema)(formData)))// Success({"a":"1"})You can express nested values using bracket notation.
Example (Nested fields)
import { Schema } from "effect"
const schema = Schema.fromFormData( Schema.Struct({ a: Schema.String, b: Schema.Struct({ c: Schema.String, d: Schema.String }) }))
const formData = new FormData()formData.append("a", "1")formData.append("b[c]", "2")formData.append("b[d]", "3")
console.log(String(Schema.decodeUnknownExit(schema)(formData)))// Success({"a":"1","b":{"c":"2","d":"3"}})If you want to decode string fields into non-string primitive values, use Schema.toCodecStringTree.
Example (Parsing non-string values)
import { Schema } from "effect"
const schema = Schema.fromFormData( Schema.toCodecStringTree( Schema.Struct({ a: Schema.Int }) ))
const formData = new FormData()formData.append("a", "1")
console.log(String(Schema.decodeUnknownExit(schema)(formData)))// Success({"a":1}) // Note: the value is a numberURLSearchParams Support
Section titled “URLSearchParams Support”Schema.fromURLSearchParams returns a schema that reads a URLSearchParams
instance, converts it into a tree record using bracket notation, and then decodes
the resulting structure using the provided schema.
The decoding process has two steps:
- Parse
URLSearchParamsinto a nested tree record. - Decode the parsed value with the given schema.
Example (Decoding a flat structure)
import { Schema } from "effect"
const schema = Schema.fromURLSearchParams( Schema.Struct({ a: Schema.String }))
const urlSearchParams = new URLSearchParams("a=1&b=2")
console.log(String(Schema.decodeUnknownExit(schema)(urlSearchParams)))// Success({"a":"1"})You can express nested values using bracket notation.
Example (Nested fields)
import { Schema } from "effect"
const schema = Schema.fromURLSearchParams( Schema.Struct({ a: Schema.String, b: Schema.Struct({ c: Schema.String, d: Schema.String }) }))
const urlSearchParams = new URLSearchParams("a=1&b[c]=2&b[d]=3")
console.log(String(Schema.decodeUnknownExit(schema)(urlSearchParams)))// Success({"a":"1","b":{"c":"2","d":"3"}})If you want to decode values that are not strings, use Schema.toCodecStringTree. This serializer preserves values such as numbers when compatible with the schema.
Example (Parsing non-string values)
import { Schema } from "effect"
const schema = Schema.fromURLSearchParams( Schema.toCodecStringTree( Schema.Struct({ a: Schema.Int }) ))
const urlSearchParams = new URLSearchParams("a=1&b=2")
console.log(String(Schema.decodeUnknownExit(schema)(urlSearchParams)))// Success({"a":1}) // Note: the value is a numberCanonical Codecs
Section titled “Canonical Codecs”When sending data over the network or storing it on disk, you need to convert your domain types to a format like JSON. Schema provides built-in support for serializing values to JSON, strings, FormData, URLSearchParams, and XML.
Canonical codecs turn one schema into another schema (a “codec”) that can serialize and deserialize values using a specific format (JSON, strings, URLSearchParams, FormData, and so on). This helps you map your domain types to formats that can only represent a limited set of values.
To keep things concrete, the rest of this page focuses on JSON.
JSON Canonical Codec
Section titled “JSON Canonical Codec”Many JavaScript values cannot be serialized to JSON in a safe and reversible way:
Date:JSON.stringify()converts a date to an ISO string, butJSON.parse()does not restore aDateobjectUint8Array,ReadonlyMap,ReadonlySet:JSON.stringify()converts them to{}, so the original data is lostSymbol,BigInt:JSON.stringify()throws errors- Custom classes and Effect data types (
Option,Result, and so on):JSON.stringify()does not know how to encode or decode them
This can lead to data loss, runtime errors, or values that decode into the wrong shape when you try to round-trip complex data through JSON.
The solution
A canonical codec describes how values that match a schema should be converted to a specific format. In practice, canonical codecs work like this:
- Annotation-based: you choose a serialization strategy by adding annotations to your schema (for example
toCodecJson,toCodecIso,toCodecStringTree, and others). - AST transformation: the codec builder walks the schema AST and produces a new schema that represents the serialized form (this traversal is handled by Effect).
- Recursive composition: codecs apply through nested structures (objects, arrays, unions, and so on) without you having to wire everything manually.
The next example shows why a custom class needs a codec when working with JSON.
Example (A custom class that does not round-trip through JSON)
import { Schema } from "effect"
class Point { constructor(public readonly x: number, public readonly y: number) {}
// Plain method on a class instance distance(other: Point): number { const dx = this.x - other.x const dy = this.y - other.y return Math.sqrt(dx * dx + dy * dy) }}
const PointSchema = Schema.instanceOf(Point)Even if encoding produces something JSON-looking, decoding cannot rebuild a Point instance (including its prototype and methods) from plain JSON data.
// Encode a Point instance using the schema, then stringify it.// This produces a plain JSON object, not a class instance.const json = JSON.stringify(Schema.encodeUnknownSync(PointSchema)(new Point(1, 2)))
console.log(json)// '{"x":1,"y":2}'
// Decode attempts to create a Point instance from parsed JSON.// This fails because JSON.parse returns a plain object, not `new Point(...)`.try { Schema.decodeUnknownSync(PointSchema)(JSON.parse(json))} catch (error) { console.error(String(error))}The same issue shows up when generating a JSON Schema document: since the schema represents a class instance and there is no JSON representation for it, the generator falls back to a placeholder.
console.log(Schema.toJsonSchemaDocument(PointSchema))// { dialect: 'draft-2020-12', schema: { type: 'null' }, definitions: {} }Configuring the Codec
Section titled “Configuring the Codec”You configure the canonical JSON codec by adding a toCodecJson annotation to your schema.
Then you call Schema.toCodecJson(schema) to produce a codec schema that can encode and decode values to and from JSON-compatible data.
Example (Encoding a class as a JSON tuple)
import { Schema, SchemaTransformation } from "effect"
class Point { constructor(public readonly x: number, public readonly y: number) {}
distance(other: Point): number { const dx = this.x - other.x const dy = this.y - other.y return Math.sqrt(dx * dx + dy * dy) }}
const PointSchema = Schema.instanceOf(Point, { toCodecJson: () => Schema.link<Point>()( // Pick a JSON representation for Point. // Here we use a fixed-length tuple: [x, y]. Schema.Tuple([Schema.Finite, Schema.Finite]), SchemaTransformation.transform({ // Decode: convert the JSON representation into a Point instance. decode: (args) => new Point(...args),
// Encode: convert a Point instance into the JSON representation. encode: (instance) => [instance.x, instance.y] as const }) )})
// Convert the schema into a JSON codec schema.const codecJson = Schema.toCodecJson(PointSchema)
// Encoding produces JSON-safe data, so it can be stringified.console.log(JSON.stringify(Schema.encodeUnknownSync(codecJson)(new Point(1, 2))))// "[1,2]"
// Decoding rebuilds the Point instance from parsed JSON.console.log(Schema.decodeUnknownSync(codecJson)(JSON.parse("[1,2]")))// Point { x: 1, y: 2 }
// JSON Schema generation now has a real representation to work with.console.dir(Schema.toJsonSchemaDocument(PointSchema), { depth: null })/*{ dialect: 'draft-2020-12', schema: { type: 'array', prefixItems: [ { type: 'number' }, { type: 'number' } ], maxItems: 2, minItems: 2 }, definitions: {}}*/When you use toCodecJson, you describe the JSON shape once (in the schema), and Effect can reuse that description in two places:
Schema.toCodecJson(...)uses it to encode and decode JSON data at runtime.Schema.toJsonSchemaDocument(...)uses it to produce a JSON Schema document for the same JSON shape.
Because both outputs come from the same annotation, they describe the same format (in this example, a two-item array [x, y]). If you change the JSON representation in toCodecJson, both the codec and the generated JSON Schema will change with it.
You can use the JSON Schema to validate or describe the JSON data (for example in OpenAPI), and use the codec schema to encode and decode values in that same format.
How toCodecJson Works
Section titled “How toCodecJson Works”When you call Schema.toCodecJson(schema), the library:
- Walks the AST: it traverses the schema’s abstract syntax tree (AST) recursively. For details, see the
SchemaASTmodule. - Finds annotations: it looks for
toCodecJsonannotations on nodes. - Applies transformations: it replaces types that are not JSON-friendly with types that are.
- Composes recursively: it builds codecs for nested schemas by combining the codecs of their parts.
Custom Encodings
Section titled “Custom Encodings”Schema.toCodecJson respects explicit encodings you add to a schema. If you choose a custom representation, that choice takes priority over the default.
Example (Custom encoding takes priority over default Date handling)
import { Schema, SchemaTransformation } from "effect"
// Custom Date encoding (Date -> number)const DateFromEpochMillis = Schema.Date.pipe( Schema.encodeTo( Schema.Number, SchemaTransformation.transform({ decode: (epochMillis) => new Date(epochMillis), encode: (date) => date.getTime() }) ))
const schema = Schema.Struct({ date1: DateFromEpochMillis, date2: Schema.Date})
const toCodecJson = Schema.toCodecJson(schema)
const data = { date1: new Date("2021-01-01"), date2: new Date("2021-01-01") }
const serialized = Schema.encodeUnknownSync(toCodecJson)(data)console.log(serialized)// { date1: 1609459200000, date2: "2021-01-01T00:00:00.000Z" }// date1 uses your custom number format, date2 uses the default ISO string formatStringTree Canonical Codec
Section titled “StringTree Canonical Codec”The StringTree codec converts all values to strings, keeping the structure but not the original types.
type StringTree = string | undefined | { readonly [key: string]: StringTree } | ReadonlyArray<StringTree>A StringTree codec turns any value into a structure made only of:
- strings
undefined- plain objects containing other
StringTreevalues - arrays of
StringTreevalues
toCodecJson vs toCodecStringTree
Section titled “toCodecJson vs toCodecStringTree”Example (Comparing JSON and StringTree codecs)
import { Schema, SchemaTransformation } from "effect"
class Point { constructor(public readonly x: number, public readonly y: number) {}
distance(other: Point): number { const dx = this.x - other.x const dy = this.y - other.y return Math.sqrt(dx * dx + dy * dy) }}
const PointSchema = Schema.instanceOf(Point, { toCodecJson: () => Schema.link<Point>()( Schema.Tuple([Schema.Finite, Schema.Finite]), SchemaTransformation.transform({ decode: (args) => new Point(...args), encode: (instance) => [instance.x, instance.y] as const }) )})
const point = new Point(1, 2)
const toCodecJson = Schema.toCodecJson(PointSchema)
const json = Schema.encodeUnknownSync(toCodecJson)(point)
// keeps numbers as numbersconsole.log(json)// [1, 2]
const toCodecStringTree = Schema.toCodecStringTree(PointSchema)
const stringTree = Schema.encodeUnknownSync(toCodecStringTree)(point)
// every leaf value becomes a stringconsole.log(stringTree)// [ '1', '2' ]ISO Canonical Codec
Section titled “ISO Canonical Codec”The ISO canonical codec (toCodecIso) converts schemas to their Iso representation. This is useful when you want to build isomorphic transformations or optics.
Example (Using the ISO canonical codec with a Class)
import { Schema } from "effect"
// Define a class schemaclass Person extends Schema.Class<Person>("Person")({ name: Schema.String, age: Schema.Number}) {}
const codecIso = Schema.toCodecIso(Person)
// The Iso type represents the "focus" of the schema.// For Class schemas, the Iso type is the struct representation// of the class fields: { readonly name: string; readonly age: number }// This allows you to convert between the class instance and a plain object// with the same shape, which is useful for optics and transformations.
const person = new Person({ name: "John", age: 30 })
const serialized = Schema.encodeUnknownSync(codecIso)(person)console.log(serialized)// { name: 'John', age: 30 }
const deserialized = Schema.decodeUnknownSync(codecIso)(serialized)console.log(deserialized)// Person { name: 'John', age: 30 }ISO serializers are mainly used internally for building optics and reusable transformations.
XML Encoder
Section titled “XML Encoder”Schema.toEncoderXml lets you serialize values to XML.
It uses the toCodecStringTree serializer internally.
Example
import { Effect, Option, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String, b: Schema.Array(Schema.NullOr(Schema.String)), c: Schema.Struct({ d: Schema.Option(Schema.String), e: Schema.Date }), f: Schema.optional(Schema.String)})
// const encoder: (t: {...}) => Effect<string, Schema.SchemaError, never>const xmlEncoder = Schema.toEncoderXml(schema)
console.log( Effect.runSync( xmlEncoder({ a: "", b: ["bar", "baz", null], c: { d: Option.some("qux"), e: new Date("2021-01-01") }, f: undefined }) ))/*<root> <a></a> <b> <item>bar</item> <item>baz</item> <item/> </b> <c> <d> <_tag>Some</_tag> <value>qux</value> </d> <e>2021-01-01T00:00:00.000Z</e> </c> <f/></root>*/Note. Schemas representing custom types are encoded as undefined:
Schema Generation and Tooling
Section titled “Schema Generation and Tooling”Schema can derive JSON Schemas, test data generators (Arbitraries), equivalence checks, optics, and more from a single schema definition.
Generating a JSON Schema from a Schema
Section titled “Generating a JSON Schema from a Schema”Basic Conversion
Section titled “Basic Conversion”By default, a schema produces a draft-2020-12 JSON Schema.
The result is a data structure including:
- the source of the JSON Schema (e.g.
draft-2020-12,draft-07, etc…) - the JSON Schema itself
- any definitions referenced by
$ref(if any)
Example (Tuple to draft-2020-12 JSON Schema)
import { Schema } from "effect"
// Define a tuple: [string, number]const schema = Schema.Tuple([Schema.String, Schema.Finite])
// Generate a draft-2020-12 JSON Schemaconst document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*Output:{ "source": "draft-2020-12", "schema": { "type": "array", "prefixItems": [ { "type": "string" }, { "type": "number" } ], "maxItems": 2, "minItems": 2 }, "definitions": {}}*/To generate a draft-07 JSON Schema, use JsonSchema.toDocumentDraft07 to convert the draft-2020-12 JSON Schema.
Example (Tuple to draft-7 JSON Schema)
import { JsonSchema, Schema } from "effect"
const schema = Schema.Tuple([Schema.String, Schema.Finite])
const doc2020_12 = Schema.toJsonSchemaDocument(schema)const doc07 = JsonSchema.toDocumentDraft07(doc2020_12)
console.log(JSON.stringify(doc07, null, 2))/*Output:{ "source": "draft-07", "schema": { "type": "array", "maxItems": 2, "minItems": 2, "items": [ { "type": "string" }, { "type": "number" } ] }, "definitions": {}}*/Attaching Standard Metadata
Section titled “Attaching Standard Metadata”Use .annotate(...) to attach standard JSON Schema annotations:
titledescriptiondefaultexamplesreadOnlywriteOnly
Example (Adding basic annotations)
import { Schema } from "effect"
const schema = Schema.NonEmptyString.annotate({ title: "Username", description: "A non-empty user name string", default: "anonymous", examples: ["alice", "bob"]})
const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*{ "source": "draft-2020-12", "schema": { "type": "string", "allOf": [ { "minLength": 1, "title": "Username", "description": "A non-empty user name string", "default": "anonymous", "examples": [ "alice", "bob" ] } ] }, "definitions": {}}*/Annotating the Encoded Side of a Transformation
Section titled “Annotating the Encoded Side of a Transformation”When a schema includes a transformation (e.g. Schema.Trim), the generated JSON Schema corresponds to the encoded side. Calling .annotate(...) on a transformation annotates the decoded side, so the annotations won’t appear in the JSON Schema output.
To annotate the encoded side, use Schema.annotateEncoded.
Example (Annotating the encoded side of Trim)
import { Schema } from "effect"
const schema = Schema.Trim.pipe( Schema.annotateEncoded({ description: "my description", title: "my title" }))
console.log(JSON.stringify(Schema.toJsonSchemaDocument(schema), null, 2))/*{ "dialect": "draft-2020-12", "schema": { "type": "string", "title": "my title", "description": "my description" }, "definitions": {}}*/Alternatively, build a custom transformation using Schema.decodeTo:
import { Schema, SchemaTransformation } from "effect"
const schema = Schema.String.annotate({ description: "my description", title: "my title"}).pipe(Schema.decodeTo(Schema.Trimmed, SchemaTransformation.trim()))
console.log(JSON.stringify(Schema.toJsonSchemaDocument(schema), null, 2))/*{ "dialect": "draft-2020-12", "schema": { "type": "string", "title": "my title", "description": "my description" }, "definitions": {}}*/Optional fields / elements
Section titled “Optional fields / elements”Optional fields are converted to optional fields or elements in the JSON Schema.
Example
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.optionalKey(Schema.String)})
const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*{ "source": "draft-2020-12", "schema": { "type": "object", "properties": { "a": { "type": "string" } }, "additionalProperties": false }, "definitions": {}}*/Fields including undefined (such as those defined unsing Schema.optional or Schema.UndefinedOr) are converted to optional fields or elements in the JSON Schema with a union with the null type.
Example
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.optional(Schema.String)})
const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*{ "source": "draft-2020-12", "schema": { "type": "object", "properties": { "a": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "additionalProperties": false }, "definitions": {}}*/Defining a JSON-safe representation for custom types
Section titled “Defining a JSON-safe representation for custom types”This example shows how Schema.toCodecJson and Schema.toJsonSchema can describe the same JSON shape for a custom type.
Headers is not JSON-friendly by default. JSON.stringify(new Headers({ a: "b" })) produces {} because the header data is not stored in enumerable properties. By adding a toCodecJson annotation, you define a JSON-safe representation and use it for both serialization and JSON Schema generation.
Example (Align a JSON serializer and JSON Schema for Headers)
import { Schema, SchemaGetter } from "effect"
const data = new Headers({ a: "b" })
// `Headers` does not serialize to JSON in a useful way by default.console.log(JSON.stringify(data))// {}
// Define a schema with a `toCodecJson` annotation.// The JSON form will be: [ [name, value], ... ].const MyHeaders = Schema.instanceOf(Headers, { toCodecJson: () => Schema.link<Headers>()( // JSON-safe representation: array of [key, value] pairs Schema.Array(Schema.Tuple([Schema.String, Schema.String])), { decode: SchemaGetter.transform((headers) => new Headers(headers.map(([key, value]) => [key, value]))), encode: SchemaGetter.transform((headers) => [...headers.entries()]) } )})
const schema = Schema.Struct({ headers: MyHeaders})
// Build a serializer that produces JSON-safe values using the `toCodecJson` annotation.const serializer = Schema.toCodecJson(schema)
const json = Schema.encodeUnknownSync(serializer)({ headers: data})
// The JSON-encoded value:console.log(json)// { headers: [ [ 'a', 'b' ] ] }
// Generate a JSON Schema that matches the JSON-safe shape produced by the serializer.const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document.schema, null, 2))/*{ "type": "object", "properties": { "headers": { "type": "array", "items": { "type": "array", "prefixItems": [ { "type": "string" }, { "type": "string" } ], "maxItems": 2, "minItems": 2 } } }, "required": [ "headers" ], "additionalProperties": false}*/
// Example (Decode a JSON-safe value using the same serializer)// If a value matches the JSON Schema above, you can decode it with the serializer.console.log(String(Schema.decodeUnknownExit(serializer)(json)))// Success({"headers":Headers([["a","b"]])})Validation Constraints
Section titled “Validation Constraints”Example
import { Schema } from "effect"
const schema = Schema.String.check(Schema.isMinLength(1))
const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*{ "source": "draft-2020-12", "schema": { "type": "string", "allOf": [ { "minLength": 1 } ] }, "definitions": {}}*/Example (Multiple filters)
import { Schema } from "effect"
const schema = Schema.String.check( Schema.isMinLength(1, { description: "description1" }), Schema.isMaxLength(2, { description: "description2" }))
const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*{ "source": "draft-2020-12", "schema": { "type": "string", "allOf": [ { "minLength": 1, "description": "description1" }, { "maxLength": 2, "description": "description2" } ] }, "definitions": {}}*/The fromJsonString combinator
Section titled “The fromJsonString combinator”With fromJsonString, the generated schema uses contentSchema to embed the JSON Schema of the decoded value.
Example (Embedding contentSchema for JSON string content)
import { Schema } from "effect"
// Original value is an object with a string field 'a'const original = Schema.Struct({ a: Schema.String })
// fromJsonString: the outer value is a string,// but its content must be valid JSON matching 'original'const schema = Schema.fromJsonString(original)
const document = Schema.toJsonSchemaDocument(schema)
console.log(JSON.stringify(document, null, 2))/*{ "source": "draft-2020-12", "schema": { "type": "string", "contentMediaType": "application/json", "contentSchema": { "type": "object", "properties": { "a": { "type": "string" } }, "required": [ "a" ], "additionalProperties": false } }, "definitions": {}}*/Generating an Arbitrary from a Schema
Section titled “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.
Most schemas do not need any extra work:
import { Schema } from "effect"import { FastCheck } from "effect/testing"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Int.check(Schema.isBetween({ minimum: 18, maximum: 80 }))})
const PersonArbitrary = Schema.toArbitrary(Person)
console.log(FastCheck.sample(PersonArbitrary, 3))Use Schema.toArbitraryLazy only when you want the caller to provide
fast-check:
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.
Filters
Section titled “Filters”Generated values are always checked by the schema filters before they are returned. The important question is whether a filter can also help choose a good generator.
Built-in filters already do this:
import { Schema } from "effect"
const Username = Schema.String.check( Schema.isMinLength(3), Schema.isMaxLength(20), Schema.isPattern(/^[a-z0-9_]+$/))
const PositiveInteger = Schema.Int.check( Schema.isGreaterThanOrEqualTo(1))
const Tags = Schema.Array(Schema.String).check( Schema.isMinLength(1), Schema.isUnique())For these schemas, toArbitrary does not generate random unconstrained strings,
numbers, or arrays and then hope the filters pass. It uses the length, range,
pattern, and uniqueness metadata to build a better generator first.
A custom filter without metadata is still correct, but may be inefficient:
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" }))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
Section titled “Reports”Use { report: true } when you want to know which filters did not guide
generation:
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.valueresult.report.warningsAn 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
Section titled “Custom Filters With Constraints”If part of a custom filter can be described as a normal generation constraint,
attach arbitrary.constraint to the filter. The constraint does not have to
prove the whole predicate; it just makes the base generator closer to the values
the predicate accepts.
import { Order, Schema } from "effect"
const isPrimeNumber = (n: number) => { if (!Number.isInteger(n) || n < 2) { return false } for (let divisor = 2; divisor * divisor <= n; divisor++) { if (n % divisor === 0) { return false } } return true}
const prime = Schema.makeFilter(isPrimeNumber, { expected: "a prime number", arbitrary: { constraint: { integer: true, ordered: { order: Order.Number, minimum: 2 } } }})
const Prime = Schema.Number.check(prime)The filter still checks primality. The constraint only tells toArbitrary not
to waste time on non-integers or numbers below 2.
Think of constraint as a small vocabulary that the current schema node can
understand:
- On strings,
minLengthandmaxLengthmean string length. - On arrays,
minLengthandmaxLengthmean array length. - On objects,
minLengthandmaxLengthmean final own-property count. - On sets, maps, hash collections, and chunks,
minLengthandmaxLengthmean final collection size. patternsapply to string generation.integer,noNaN,noInfinity,valid, anduniqueare enabled when any contributing filter sets them.orderedstores bounds for ordered values such as numbers, bigints, dates,DateTime, andBigDecimal.
Fields that do not make sense for the current node are ignored. The final filter check still validates every generated value.
Custom Filters With Candidates
Section titled “Custom Filters With Candidates”Use a candidate when the filter cannot be expressed with the constraint vocabulary.
import { Schema } from "effect"
const reverse = (s: string) => Array.from(s).reverse().join("")
const isPalindrome = (s: string) => s === reverse(s)
const palindrome = Schema.makeFilter( isPalindrome, { expected: "a palindrome", arbitrary: { candidate: { weight: 5, make: (fc) => fc.string().map((half) => `${half}${reverse(half)}`) } } })
const Palindrome = Schema.String.check(palindrome)A candidate is an extra source used together with the schema node’s base
generator. The base generator has weight 1. A candidate has weight 1 unless
you set another positive integer weight.
With one candidate at weight 5, fast-check tries the candidate roughly five
times as often as the base generator. Candidate values are still checked by all
filters, so a bad candidate can waste attempts but cannot produce invalid
values.
make receives the arbitrary context and may return undefined when the
candidate should not be used for that context.
Schema-Level Overrides
Section titled “Schema-Level Overrides”Use a toArbitrary annotation when you want to replace the generator for a
schema node.
The annotation is not limited to declaration schemas. You can attach it to a
normal schema with .annotate(...):
import { Schema } from "effect"
const Name = Schema.String.annotate({ toArbitrary: () => (fc) => fc.constantFrom("Alice", "Bob", "Carol")})Put override annotations on base schemas when possible, before adding filters:
const Name = Schema.String.annotate({ toArbitrary: () => (fc) => fc.constantFrom("Alice", "Bob", "Carol")}).check(Schema.isMinLength(1))This shape is easier to reason about. The override provides the base generator; the filter remains a normal filter. Schema still checks generated values at the end.
Avoid putting an override on a schema that already has filters unless the override intentionally handles those filters too:
const Name = Schema.String.check(Schema.isMinLength(1)).annotate({ toArbitrary: () => (fc) => fc.constant("")})This is valid TypeScript, but it is a bad generator: it always generates a value that the filter rejects.
The second argument of a toArbitrary hook is the arbitrary context. Its
constraint field contains constraints collected from filters on the same
schema node as the override. If the override is placed before .check(...), the
context does not include the later filters. If the override is placed after
.check(...), the context includes those filters and the override must respect
them.
context.recursion is present while deriving inside a recursive schema.
Declaration Schemas
Section titled “Declaration Schemas”Declaration schemas are opaque to Schema. If you define one, provide a
toArbitrary hook.
For an atomic declaration, return a normal fast-check arbitrary:
import { Schema } from "effect"
const Url = Schema.instanceOf(globalThis.URL, { title: "URL", toArbitrary: () => (fc) => fc.webUrl().map((s) => new globalThis.URL(s))})Generic declarations receive one derivation per type parameter:
arbitrary: the normal generator for the type parameter.terminal: a finite generator for the type parameter, used to close recursive generation.
For an opaque wrapper type, you usually map both sources in the same way:
import { Effect, Option, Schema, SchemaIssue, SchemaParser } from "effect"
class Box<A> { private constructor(private readonly value: A) {}
static make<A>(value: A): Box<A> { return new Box(value) }
static unbox<A>(box: Box<A>): A { return box.value }}
const isBox = (u: unknown): u is Box<unknown> => u instanceof Box
const BoxSchema = <A extends Schema.Top>(value: A) => Schema.declareConstructor<Box<A["Type"]>, Box<A["Encoded"]>>()( [value], ([valueCodec]) => (input, ast, options) => { if (!isBox(input)) { return Effect.fail(new SchemaIssue.InvalidType(ast, Option.some(input))) } return Effect.map( SchemaParser.decodeUnknownEffect(valueCodec)(Box.unbox(input), options), Box.make ) }, { toArbitrary: ([value]) => () => ({ arbitrary: value.arbitrary.map(Box.make), terminal: value.terminal?.map(Box.make) }) } )This looks like duplicated code, but it is not the same generator twice. It is the same opaque constructor applied to two different sources.
Suppose someone later builds a recursive schema like this:
interface Tree<A> { readonly value: A readonly children: ReadonlyArray<Tree<A>>}
type BoxedTree<A> = Box<Tree<A>>Box does not know whether A is recursive. If A is Tree<A>, then
value.arbitrary may generate a recursive tree, while value.terminal is the
finite tree generator used when the recursion budget is exhausted. Mapping both
sources through Box.make preserves that information. If Box returned only
arbitrary, it would hide the finite path from outer recursive schemas.
If the type parameter has no finite terminal generator, value.terminal is
undefined, and the wrapper cannot provide a terminal branch either.
Integration with Synthetic Data Generation Tools
Section titled “Integration with Synthetic Data Generation Tools”Synthetic data libraries such as @faker-js/faker are useful when the generated
values should look realistic. Put them behind a Fast-Check arbitrary instead of
calling them directly, so Fast-Check still controls randomness and shrinking.
import { faker } from "@faker-js/faker"import { Schema } from "effect"import { FastCheck } from "effect/testing"
/** * Make it easy to plug a Faker generator into a Schema's `toArbitrary` override. * The seed comes from Fast-Check so data is reproducible and shrinks correctly. */function fake<A>( gen: (f: typeof faker) => A): Schema.Annotations.ToArbitrary.Declaration<A, readonly []> { return () => (fc) => fc.nat().map((seed) => { faker.seed(seed) return gen(faker) })}
const FirstName = Schema.String.annotate({ toArbitrary: fake((faker) => faker.person.firstName())})
const LastName = Schema.String.annotate({ toArbitrary: fake((faker) => faker.person.lastName())})
const JobTitle = Schema.String.annotate({ toArbitrary: fake((faker) => faker.person.jobTitle())})
const Company = Schema.String.annotate({ toArbitrary: fake((faker) => faker.company.name())})
const Person = Schema.Struct({ firstName: FirstName, lastName: LastName, jobTitle: JobTitle, company: Company})
console.log(FastCheck.sample(Schema.toArbitrary(Person), 3))These overrides are useful because the values have domain shape: names look like names, job titles look like job titles, and companies look like companies. For plain numeric ranges, prefer Schema constraints and the default arbitrary derivation.
If you combine a Faker source with filters, put the override on the base schema
first and add filters afterwards. This keeps the responsibilities simple: the
override chooses a realistic source, and the filter remains the final validation
rule. If you put the override after .check(...), the override must respect
those filters itself, or generation will spend time producing values that are
rejected.
Generating an Equivalence from a Schema
Section titled “Generating an Equivalence from a Schema”An equivalence function checks whether two values are structurally equal according to the schema’s definition. Schema derives this automatically, so you do not need to write manual comparison logic.
Example (Deriving equivalence for a basic schema)
import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String, b: Schema.Number})
const equivalence = Schema.toEquivalence(schema)Declarations
Section titled “Declarations”Example (Providing a custom equivalence for a class)
import { Schema } from "effect"
class MyClass { constructor(readonly a: string) {}}
const schema = Schema.instanceOf(MyClass, { toEquivalence: () => (x, y) => x.a === y.a})
const equivalence = Schema.toEquivalence(schema)Overrides
Section titled “Overrides”You can override the derived equivalence for a schema using overrideToEquivalence. This is useful when the default derivation does not fit your requirements.
Example (Overriding equivalence for a struct)
import { Equivalence, Schema } from "effect"
const schema = Schema.Struct({ a: Schema.String, b: Schema.Number}).pipe(Schema.overrideToEquivalence(() => Equivalence.make((x, y) => x.a === y.a)))
const equivalence = Schema.toEquivalence(schema)Generating an Optic from a Schema
Section titled “Generating an Optic from a Schema”Optics provide a composable way to read and update deeply nested values without mutating the original object. Schema can derive optics automatically from your schema definition.
Problem
Section titled “Problem”The Optic module only works with plain JavaScript objects and collections (structs, records, tuples, and arrays).
This can feel restrictive when working with custom types.
To work around this, you can define an Iso between your custom type and a plain JavaScript object.
Example (Defining an Iso manually between a custom type and a plain JavaScript object)
import { Optic, Schema } from "effect"
// Define custom schema-based classesclass A extends Schema.Class<A>("A")({ s: Schema.String }) {}class B extends Schema.Class<B>("B")({ a: A }) {}
// Create an Iso that converts between B and a plain objectconst iso = Optic.makeIso<B, { readonly a: { readonly s: string } }>( (s) => ({ a: { s: s.a.s } }), // forward transformation (a) => new B({ a: new A({ s: a.a.s }) }) // backward transformation)
// Build an optic that drills down to the "s" field inside "a"const _s = iso.key("a").key("s")
console.log(_s.replace("b", new B({ a: new A({ s: "a" }) })))// B { a: A { s: 'b' } }Solution
Section titled “Solution”Manually creating Iso instances is repetitive and error-prone.
To simplify this, the library provides a helper function that generates an Iso directly from a schema.
This allows you to keep working with plain JavaScript objects and collections while still benefiting from schema definitions.
Example (Generating an Iso automatically from a schema)
import { Schema } from "effect"
class A extends Schema.Class<A>("A")({ s: Schema.String }) {}class B extends Schema.Class<B>("B")({ a: A }) {}
// Automatically generate an Iso from the schema of B// const iso: Iso<B, { readonly a: { readonly s: string } }>const iso = Schema.toIso(B)
const _s = iso.key("a").key("s")
console.log(_s.replace("b", new B({ a: new A({ s: "a" }) })))// B { a: A { s: 'b' } }Using the Differ Module for Type-Safe JSON Patches
Section titled “Using the Differ Module for Type-Safe JSON Patches”The Differ module lets you compute and apply JSON Patch (RFC 6902) changes for any value described by a Schema. You give it a schema once, then use the returned differ to produce a patch from an old value to a new value, and to apply that patch.
Example (Compare two values and apply the patch)
import { Schema } from "effect"
// Describe the shape of your dataconst schema = Schema.Struct({ id: Schema.Number, name: Schema.String, price: Schema.Number})
// Build a differ tied to the schemaconst differ = Schema.toDifferJsonPatch(schema)
// Prepare two values to compareconst oldValue = { id: 1, name: "a", price: 1 }const newValue = { id: 1, name: "b", price: 2 }
// Compute a JSON Patch document (an array of operations)const jsonPatch = differ.diff(oldValue, newValue)console.log(jsonPatch)/*[ { op: 'replace', path: '/name', value: 'b' }, { op: 'replace', path: '/price', value: 2 }]*/
// Apply the patch to the old value to get the new valueconst patched = differ.patch(oldValue, jsonPatch)console.log(patched)// { id: 1, name: 'b', price: 2 }Works with custom types too
Section titled “Works with custom types too”Example (Compare two custom types)
import { Schema } from "effect"
class A extends Schema.Class<A>("A")({ n: Schema.Number }) {}class B extends Schema.Class<B>("B")({ a: A }) {}
const differ = Schema.toDifferJsonPatch(B)
const oldValue = new B({ a: new A({ n: 0 }) })const newValue = new B({ a: new A({ n: 1 }) })
const patch = differ.diff(oldValue, newValue)console.log(patch)// [ { op: 'replace', path: '/a/n', value: 1 } ]
console.log(differ.patch(oldValue, patch))// B { a: A { n: 1 } }How it works
Section titled “How it works”The idea is simple: if you have a Schema for a type T, you can serialize any T to JSON and back. That lets us compute and apply JSON Patch on the JSON view, while keeping the public API typed as T.
-
diff(oldValue, newValue)- Encode
oldValue: TandnewValue: Tto JSON with the schema serializer. - Compute a JSON Patch document between the two JSON values.
- Return that patch (an array of
"add" | "remove" | "replace"operations).
- Encode
-
patch(oldValue, patch)- Encode
oldValue: Tto JSON. - Apply the JSON Patch to the JSON value.
- Decode the patched JSON back to
Tusing the schema.
- Encode
This approach keeps patches independent from TypeScript types and uses the schema as the guardrail when turning JSON back into T.
Schema Representation
Section titled “Schema Representation”The SchemaRepresentation module converts a Schema into a portable data structure and back again.
Use it when you need to:
- store schemas on disk (for example in a cache)
- send schemas over the network
- rebuild runtime schemas later
- convert to JSON Schema (Draft 2020-12)
- generate TypeScript code that recreates schemas
At a high level:
fromAST/fromASTsturn a schema AST into aDocument/MultiDocumentDocumentFromJson(schema) round-trip that document through JSONtoSchemarebuilds a runtimeSchemafrom the stored representationtoJsonSchemaDocumentproduces a Draft 2020-12 JSON Schema documenttoCodeDocumentprepares data for code generation (viatoMultiDocument)
flowchart TD S[Schema] -->|fromAST|D{"SchemaRepresentation.Document"} S -->|fromASTs|MD{"SchemaRepresentation.MultiDocument"} JS["JSON Schema (draft-07, draft-2020-12, openapi-3.0, openapi-3.1)"] -->JSD JD --> JS JD["JsonSchema.Document"] -->|fromJsonSchemaDocument|D D <--> |"DocumentFromJson (schema)"|JSON D --> |toJsonSchemaDocument|JD D --> |toSchema|S MD --> |toCodeDocument|CodeDocument["CodeDocument"] D --> |toMultiDocument|MD MD --> |toJsonSchemaMultiDocument|JMD[JsonSchema.MultiDocument] MD <--> |"MultiDocumentFromJson (schema)"|JSONThe data model
Section titled “The data model”Representation
Section titled “Representation”A Representation is a tagged object tree (_tag fields like "String", "Objects", "Union", …). It describes the structure of a schema in a JSON-friendly way.
Only a subset of schema features can be represented. See “Limitations” below.
Document
Section titled “Document”A Document has:
representation: the rootRepresentationreferences: a map of named definitions used by the root representation
References let the representation share definitions and support recursion.
MultiDocument
Section titled “MultiDocument”A MultiDocument stores multiple root representations that share the same references table.
This is useful if you want to serialize a set of schemas together, or if you want to generate code for multiple schemas while emitting shared definitions only once.
Limitations
Section titled “Limitations”SchemaRepresentation is meant for schemas that can be described without user code.
That has a few consequences.
Transformations are not supported
Section titled “Transformations are not supported”The representation format describes the schema’s shape and a set of known checks. It does not store transformation logic.
Schemas that rely on transformations cannot be round-tripped, including:
Schema.transform(...)Schema.encodeTo(...)- custom codecs or any schema that changes how values are encoded/decoded
If you serialize a transformed schema, the transformation logic will be lost. When you rebuild it with toSchema, you will only get the structural schema.
Aside (Why transformations are excluded)
A transformation is user code (functions). JSON cannot store functions, and serializing functions as strings would not be safe or portable.
Only built-in checks can be represented
Section titled “Only built-in checks can be represented”Checks are stored as Filter / FilterGroup nodes with a small meta object.
Only checks that match the built-in meta definitions are supported, such as:
- string checks:
isMinLength,isPattern,isUUID, … - number checks:
isInt,isBetween,isMultipleOf, … - bigint checks:
isGreaterThanBigInt, … - array checks:
isLength,isUnique, … - object checks:
isMinProperties, … - date checks:
isBetweenDate, …
Custom predicates (for example Schema.filter((x) => ...)) are not supported, because the representation has nowhere to store the function.
Annotations are filtered
Section titled “Annotations are filtered”Annotations are stored as a record, but:
- only values that look like JSON primitives (plus
bigintandsymbolin the in-memory form) are kept - some annotation keys are dropped using an internal blacklist
In practice, documentation annotations like title and description are preserved, while complex values (functions, instances, nested objects) are ignored.
Declarations need a reviver
Section titled “Declarations need a reviver”Some runtime schemas are represented as Declaration nodes. Rebuilding them requires a “reviver” function.
toSchema ships with a default reviver (toSchemaDefaultReviver) that recognizes a fixed set of constructors, including:
effect/Option,effect/Result,effect/Exit, …ReadonlyMap,ReadonlySetRegExp,URL,DateFormData,URLSearchParams,Uint8ArrayDateTime.Utc,effect/Duration
If your document contains other declarations, pass a custom reviver to toSchema.
JSON round-tripping
Section titled “JSON round-tripping”toJson / fromJson
Section titled “toJson / fromJson”toJson(document)returns JSON-compatible data (safe toJSON.stringify)fromJson(unknown)validates and parses JSON data back into aDocument
Internally, these functions use a canonical JSON codec for Document$. This is why values like bigint in annotations are encoded as strings in the JSON form and restored on decode.
Rebuilding runtime schemas
Section titled “Rebuilding runtime schemas”toSchema
Section titled “toSchema”toSchema(document) walks the representation tree and recreates a runtime schema.
What it does:
- rebuilds the structural schema nodes (
Struct,Tuple,Union, …) - resolves references from
document.references - supports recursive references using
Schema.suspend - re-attaches stored annotations via
.annotate(...)and.annotateKey(...) - re-applies supported checks via
.check(...)
If you need custom handling for declarations:
SchemaRepresentation.toSchema(document, { reviver: (declaration, recur) => { // Return a runtime schema to override how a Declaration is rebuilt. // Return undefined to fall back to the default behavior. return undefined }})JSON Schema output
Section titled “JSON Schema output”toJsonSchemaDocument / toJsonSchemaMultiDocument
Section titled “toJsonSchemaDocument / toJsonSchemaMultiDocument”These functions convert a Document or MultiDocument into a Draft 2020-12 JSON Schema document.
This is useful for tooling that expects JSON Schema, or for producing OpenAPI-compatible schema pieces (depending on your pipeline).
Code generation
Section titled “Code generation”toCodeDocument
Section titled “toCodeDocument”toCodeDocument converts a MultiDocument into a structure that is convenient for generating TypeScript source.
It:
- sorts references so non-recursive definitions can be emitted in dependency order
- keeps recursive definitions separate (they must be emitted using
Schema.suspend) - sanitizes reference names into valid JavaScript identifiers
- collects extra artifacts that must be emitted (enums, symbols, imports)
You can customize:
sanitizeReferenceto control how$refstrings become identifiersreviverto generate custom code forDeclarationnodes
Error Handling and Formatting
Section titled “Error Handling and Formatting”When validation fails, Schema produces structured error objects that describe what went wrong. Formatters turn those error objects into human-readable messages you can display to users or write to logs.
Formatters
Section titled “Formatters”StandardSchemaV1 formatter
Section titled “StandardSchemaV1 formatter”The StandardSchemaV1 formatter is used by Schema.toStandardSchemaV1 and will return a StandardSchemaV1.FailureResult object:
export interface FailureResult { /** The issues of failed validation. */ readonly issues: ReadonlyArray<Issue>}
export interface Issue { /** The error message of the issue. */ readonly message: string /** The path of the issue. */ readonly path: ReadonlyArray<PropertyKey>}You can customize the messages of the Issue object in two main ways:
- By passing formatter hooks
- By annotating schemas with
messageormessageMissingKeyormessageUnexpectedKey
For the exact rule used by the default formatter for identifiers, filter
expected, and message annotations, see
Filter error messages and schema identifiers.
Formatter hooks let you define custom messages in one place and apply them across different schemas. This can help avoid repeating message definitions and makes it easier to update them later.
Hooks are required. There is a default implementation that can be overridden only for demo purposes. This design helps keep the bundle size smaller by avoiding unused message formatting logic.
There are two kinds of hooks:
LeafHook— for issues that occur at leaf nodes in the schema.CheckHook— for custom validation checks.
LeafHook handles these issue types:
InvalidTypeInvalidValueMissingKeyUnexpectedKeyForbiddenOneOf
CheckHook handles Check issues, such as failed filters / refinements.
Example (Default hooks)
Default hooks are just for demo purposes:
- LeafHook: returns the issue tag
- CheckHook: returns the meta infos of the check as a string
import { Effect, Schema, SchemaIssue } from "effect"
const schema = Schema.Struct({ a: Schema.NonEmptyString, b: Schema.NonEmptyString})
Schema.decodeUnknownEffect(schema)({ b: "" }, { errors: "all" }) .pipe( Effect.mapError((error) => SchemaIssue.makeFormatterStandardSchemaV1()(error.issue)), Effect.runPromise ) .then(console.log, (a) => console.dir(a, { depth: null }))/*Output:{ issues: [ { path: [ 'a' ], message: 'Missing key' }, { path: [ 'b' ], message: 'Expected a value with a length of at least 1, got ""' } ]}*/Customizing messages
Section titled “Customizing messages”If a schema has a message annotation, it will take precedence over any formatter hook.
To make the examples easier to follow, we define a helper function that prints formatted validation messages using SchemaFormatter.
Example utilities
import { Exit, Schema, SchemaIssue } from "effect"import i18next from "i18next"
i18next.init({ lng: "en", resources: { en: { translation: { "string.mismatch": "Please enter a valid string", "string.minLength": "Please enter at least {{minLength}} character(s)", "struct.missingKey": "This field is required", "struct.mismatch": "Please enter a valid object", "default.mismatch": "Invalid type", "default.invalidValue": "Invalid value", "default.forbidden": "Forbidden operation", "default.oneOf": "Too many successful values", "default.check": "The value does not match the check" } } }})
export const t = i18next.t
export function getLogIssues(options?: { readonly leafHook?: SchemaIssue.LeafHook | undefined readonly checkHook?: SchemaIssue.CheckHook | undefined}) { return <S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, input: unknown) => { console.log( String( Schema.decodeUnknownExit(schema)(input, { errors: "all" }).pipe( Exit.mapError((err) => SchemaIssue.makeFormatterStandardSchemaV1(options)(err.issue).issues) ) ) ) }}Example (Using hooks to translate common messages)
import { Schema } from "effect"import { getLogIssues, t } from "./utils.js"
const Person = Schema.Struct({ name: Schema.String.check(Schema.isNonEmpty())})
// Configure hooks to customize how issues are renderedconst logIssues = getLogIssues({ // Format leaf-level issues (missing key, wrong type, etc.) leafHook: (issue) => { switch (issue._tag) { case "InvalidType": { if (issue.ast._tag === "String") { return t("string.mismatch") // Wrong type for a string } else if (issue.ast._tag === "Objects") { return t("struct.mismatch") // Value is not an object } return t("default.mismatch") // Fallback for other types } case "InvalidValue": { return t("default.invalidValue") } case "MissingKey": return t("struct.missingKey") case "UnexpectedKey": return t("struct.unexpectedKey") case "Forbidden": return t("default.forbidden") case "OneOf": return t("default.oneOf") } }, // Format custom check errors (like isMinLength or user-defined validations) checkHook: (issue) => { const meta = issue.filter.annotations?.meta if (meta) { switch (meta._tag) { case "isMinLength": { return t("string.minLength", { minLength: meta.minLength }) } } } return t("default.check") }})
// Invalid object (not even a struct)logIssues(Person, null)// Failure(Cause([Fail([{"path":[],"message":"Please enter a valid object"}])]))
// Missing "name" keylogIssues(Person, {})// Failure(Cause([Fail([{"path":["name"],"message":"This field is required"}])]))
// "name" has the wrong typelogIssues(Person, { name: 1 })// Failure(Cause([Fail([{"path":["name"],"message":"Please enter a valid string"}])]))
// "name" is an empty stringlogIssues(Person, { name: "" })// Failure(Cause([Fail([{"path":["name"],"message":"Please enter at least 1 character(s)"}])]))Inline custom messages
Section titled “Inline custom messages”You can attach custom error messages directly to a schema using annotations. These messages can either be plain strings or functions that return strings. This is useful when you want to provide field-specific wording or localization without relying on formatter hooks.
Example (Attaching custom messages to a struct field)
import { Schema } from "effect"import { getLogIssues, t } from "./utils.js"
const Person = Schema.Struct({ name: Schema.String // Message for invalid type (e.g., number instead of string) .annotate({ message: t("string.mismatch") }) // Message to show when the key is missing .annotateKey({ messageMissingKey: t("struct.missingKey") }) // Message to show when the string is empty .check(Schema.isNonEmpty({ message: t("string.minLength", { minLength: 1 }) }))}) // Message to show when the whole object has the wrong shape .annotate({ message: t("struct.mismatch") })
// Use defaults for leaf and check hooksconst logIssues = getLogIssues()
// Invalid object (not even a struct)logIssues(Person, null)// Failure(Cause([Fail([{"path":[],"message":"Please enter a valid object"}])]))
// Missing "name" keylogIssues(Person, {})// Failure(Cause([Fail([{"path":["name"],"message":"This field is required"}])]))
// "name" has the wrong typelogIssues(Person, { name: 1 })// Failure(Cause([Fail([{"path":["name"],"message":"Please enter a valid string"}])]))
// "name" is an empty stringlogIssues(Person, { name: "" })// Failure(Cause([Fail([{"path":["name"],"message":"Please enter at least 1 character(s)"}])]))Sending a FailureResult over the wire
Section titled “Sending a FailureResult over the wire”You can use the Schema.StandardSchemaV1FailureResult schema to send a StandardSchemaV1.FailureResult over the wire.
Example (Sending a FailureResult over the wire)
import { Schema, SchemaIssue, SchemaParser } from "effect"
const b = Symbol.for("b")
const schema = Schema.Struct({ a: Schema.NonEmptyString, [b]: Schema.Finite, c: Schema.Tuple([Schema.String])})
const r = SchemaParser.decodeUnknownExit(schema)({ a: "", c: [] }, { errors: "all" })
if (r._tag === "Failure") { const failures = r.cause.failures if (failures[0]?._tag === "Fail") { const failureResult = SchemaIssue.makeFormatterStandardSchemaV1()(failures[0].error) const serializer = Schema.toCodecJson(Schema.StandardSchemaV1FailureResult) console.dir(Schema.encodeSync(serializer)(failureResult), { depth: null }) }}/*{ issues: [ { message: 'Expected a value with a length of at least 1, got ""', path: [ 'a' ] }, { message: 'Missing key', path: [ 'c', 0 ] }, { message: 'Missing key', path: [ 'Symbol(b)' ] } ]}*/Middlewares
Section titled “Middlewares”A middleware wraps around the decoding or encoding process, letting you intercept errors, provide fallback values, or inject services. The most common use case is returning a default value when decoding fails.
Fallbacks
Section titled “Fallbacks”You can use Schema.catchDecoding to return a fallback value when decoding fails.
This API uses an Effect without a context. If you need a fallback value that depends on a service, use Schema.catchDecodingWithContext.
Example (Returning a simple fallback value)
import { Effect, Schema } from "effect"
// Provide a fallback string when decoding does not succeedconst schema = Schema.String.pipe(Schema.catchDecoding(() => Effect.succeedSome("b")))
console.log(String(Schema.decodeUnknownExit(schema)(null)))// Success("b")You can also return Option.none() to omit a field from the output.
This is useful when working with optional fields.
Example (Omitting a field when decoding fails)
import { Effect, Schema } from "effect"
// Omit the field when decoding does not succeedconst schema = Schema.Struct({ a: Schema.optionalKey(Schema.String).pipe(Schema.catchDecoding(() => Effect.succeedNone))})
console.log(String(Schema.decodeUnknownExit(schema)({ a: null })))// Success({})Using a Service to provide a fallback value
Section titled “Using a Service to provide a fallback value”You can use Schema.catchDecodingWithContext to get a fallback value from a service.
Example (Retrieving a fallback value from a service)
import { Context, Effect, Option, Schema } from "effect"
// Define a service that provides a fallback valueclass Service extends Context.Service<Service, { fallback: Effect.Effect<string> }>()("Service") {}
// ┌─── Codec<string, string, Service, never>// ▼const schema = Schema.revealCodec( Schema.revealCodec( Schema.String.pipe( Schema.catchDecodingWithContext(() => Effect.gen(function*() { const service = yield* Service return Option.some(yield* service.fallback) }) ) ) ))
// Provide the service during decoding// ┌─── Codec<string, string, never, never>// ▼const provided = Schema.revealCodec( schema.pipe(Schema.middlewareDecoding(Effect.provideService(Service, { fallback: Effect.succeed("b") }))))
console.log(String(Schema.decodeUnknownExit(provided)(null)))// Success("b")Advanced Topics
Section titled “Advanced Topics”This section covers Schema’s internal type machinery and advanced features. You don’t need this to use Schema — it’s here for library authors and advanced users who want to understand or extend the type system.
A “schema” is a strongly typed wrapper around an untyped AST (abstract syntax tree) node.
The base interface is Bottom, which sits at the bottom of the schema type hierarchy. In Schema v4, the number of tracked type parameters has increased to 15, allowing for more precise and flexible schema definitions.
export interface Bottom< out T, out E, out RD, out RE, out Ast extends AST.AST, out RebuildOut extends Top, out TypeMakeIn = T, out Iso = T, in out TypeParameters extends ReadonlyArray<Top> = readonly [], out TypeMake = TypeMakeIn, out TypeMutability extends Mutability = "readonly", out TypeOptionality extends Optionality = "required", out TypeConstructorDefault extends ConstructorDefault = "no-default", out EncodedMutability extends Mutability = "readonly", out EncodedOptionality extends Optionality = "required"> extends Pipeable.Pipeable { readonly [TypeId]: typeof TypeId
readonly ast: Ast readonly "Rebuild": RebuildOut readonly "~type.parameters": TypeParameters
readonly Type: T readonly Encoded: E readonly DecodingServices: RD readonly EncodingServices: RE
readonly "~type.make.in": TypeMakeIn readonly "~type.make": TypeMake // useful to type the `refine` interface readonly "~type.constructor.default": TypeConstructorDefault readonly Iso: Iso
readonly "~type.mutability": TypeMutability readonly "~type.optionality": TypeOptionality readonly "~encoded.mutability": EncodedMutability readonly "~encoded.optionality": EncodedOptionality
annotate(annotations: Annotations.Bottom<this["Type"], this["~type.parameters"]>): this["Rebuild"] annotateKey(annotations: Annotations.Key<this["Type"]>): this["Rebuild"] check(...checks: readonly [AST.Check<this["Type"]>, ...Array<AST.Check<this["Type"]>>]): this["Rebuild"] rebuild(ast: this["ast"]): this["Rebuild"] /** * @throws {Error} The issue is contained in the error cause. */ make(input: this["~type.make.in"], options?: MakeOptions): this["Type"]}Parameter Overview
Section titled “Parameter Overview”T: the decoded output typeE: the encoded representationRD: the type of the services required for decodingRE: the type of the services required for encodingAst: the AST node typeRebuildOut: the type returned when modifying the schema (namely when you add annotations or checks)TypeMakeIn: the type of the input to themakeconstructorIso: the type of the focus of the defaultOptic.IsoTypeParameters: the type of the type parameters
Contextual information about the schema (when the schema is used in a composite schema such as a struct or a tuple):
TypeMake: the type used to construct the valueTypeReadonly: whether the schema is readonly on the type sideTypeIsOptional: whether the schema is optional on the type sideTypeDefault: whether the constructor has a default valueEncodedIsReadonly: whether the schema is readonly on the encoded sideEncodedIsOptional: whether the schema is optional on the encoded side
AST Node Structure
Section titled “AST Node Structure”Every schema is based on an AST node with a consistent internal shape:
classDiagram class ASTNode { + annotations + checks + encoding + context + ...specific node fields... }annotations: metadata attached to the schema nodechecks: an array of validation rulesencoding: a list of transformations that describe how to encode the valuecontext: includes details used when the schema appears inside composite schemas such as structs or tuples (e.g., whether the field is optional or mutable)
Type Hierarchy
Section titled “Type Hierarchy”The Bottom type is the foundation of the schema system. It carries all internal type parameters used by the library.
Higher-level schema types build on this base by narrowing those parameters. Common derived types include:
Top: a generic schema with no fixed shapeSchema<T>: represents the TypeScript typeTCodec<T, E, RD, RE>: a schema that decodesEtoTand encodesTtoE, possibly requiring servicesRDandRE
flowchart TD T[Top] --> S["Schema[T]"] S --> C["Codec[T, E, RD, RE]"] S --> O["Optic[T, Iso]"] C --> B["Bottom[T, E, RD, RE, Ast, RebuildOut, TypeMakeIn, Iso, TypeParameters, TypeMake, TypeMutability, TypeOptionality, TypeConstructorDefault, EncodedMutability, EncodedOptionality]"]Best Practices
Section titled “Best Practices”Use Top, Schema, and Codec as constraints only. Do not use them as explicit annotations or return types.
Example (Prefer constraints over wide annotations)
import { Schema } from "effect"
// ✅ Use as a constraint. S can be any schema that extends Top.declare function foo<S extends Schema.Top>(schema: S)
// ❌ Do not return Codec directly. It erases useful type information.declare function bar(): Schema.Codec<number, string>
// ❌ Avoid wide annotations that lose details baked into a specific schema.const schema: Schema.Codec<number, string> = Schema.FiniteFromStringThese wide types reset other internal parameters to defaults, which removes useful information:
Top: all type parameters are set to defaultsSchema: all type parameters exceptTypeare set to defaultsCodec: all type parameters exceptType,Encoded,DecodingServices,EncodingServicesare set to defaults
Example (How wide annotations erase information)
import { Schema } from "effect"
// Read a hidden type-level property from a concrete schematype TypeMutability = (typeof Schema.FiniteFromString)["~type.mutability"] // "readonly"
const schema: Schema.Codec<number, string> = Schema.FiniteFromString
// After widening to Codec<...>, the mutability info is broadenedtype TypeMutability2 = (typeof schema)["~type.mutability"] // "readonly" | "mutable"Typed Annotations
Section titled “Typed Annotations”You can retrieve typed annotations with the Schema.resolveAnnotations function. The function is called “resolve” rather than “get” because it performs a lookup: if the schema has checks, the annotations are taken from the last check; otherwise they are taken from the base schema instance. This means annotations placed on a check (e.g. via .check(myCheck.annotate({ ... }))) take precedence over annotations on the schema itself.
Example (Resolving annotations from a base schema)
import { Schema } from "effect"
const schema = Schema.String.annotate({ title: "my string" })
console.log(Schema.resolveAnnotations(schema))// Output: { title: "my string" }Example (Annotations on the last check take precedence)
import { Schema } from "effect"
const schema = Schema.String .annotate({ title: "base" }) .check(Schema.isNonEmpty().annotate({ title: "from check" }))
console.log(Schema.resolveAnnotations(schema)?.title)// Output: "from check"You can also extend the available annotations by adding your own in a module declaration file.
Example (Adding a custom annotation for versioning)
import { Schema } from "effect"
// Extend the Annotations interface with a custom `version` annotationdeclare module "effect/Schema" { namespace Annotations { interface Augment { readonly version?: readonly [major: number, minor: number, patch: number] | undefined } }}
// The `version` annotation is now recognized by the TypeScript compilerconst schema = Schema.String.annotate({ version: [1, 2, 0] })
// const version: readonly [major: number, minor: number, patch: number] | undefinedconst version = Schema.resolveAnnotations(schema)?.["version"]
if (version) { // Access individual parts of the version console.log(version[1]) // Output: 2}Key-level Annotations
Section titled “Key-level Annotations”Key-level annotations are attached via annotateKey and apply to a field’s position inside a Struct or Tuple rather than to the field’s value type. Use Schema.resolveAnnotationsKey to retrieve them.
Example (Resolving key-level annotations)
import { Schema } from "effect"
const schema = Schema.String.annotateKey({ messageMissingKey: "required" })
console.log(Schema.resolveAnnotationsKey(schema))// Output: { messageMissingKey: "required" }Generics Improvements
Section titled “Generics Improvements”Using generics in schema composition and filters can be difficult.
The plan is to make generics covariant and easier to use.
Separate Requirement Type Parameters
Section titled “Separate Requirement Type Parameters”In real-world applications, decoding and encoding often have different dependencies. For example, decoding may require access to a database, while encoding does not.
To support this, schemas now have two separate requirement parameters:
interface Codec<T, E, RD, RE> { // ...}RD: services required only for decodingRE: services required only for encoding
This makes it easier to work with schemas in contexts where one direction has no external dependencies.
Example (Decoding requirements are ignored during encoding)
import type { Effect } from "effect"import { Context, Schema } from "effect"
// A service that retrieves full user info from an IDclass UserDatabase extends Context.Service< UserDatabase, { getUserById: (id: string) => Effect.Effect<{ readonly id: string; readonly name: string }> }>()("UserDatabase") {}
// Schema that decodes from an ID to a user object using the database,// but encodes just the IDdeclare const User: Schema.Codec< { id: string; name: string }, string, UserDatabase, // Decoding requires the database never // Encoding does not require any services>
// ┌─── Effect<{ readonly id: string; readonly name: string; }, Schema.SchemaError, UserDatabase>// ▼const decoding = Schema.decodeEffect(User)("user-123")
// ┌─── Effect<string, Schema.SchemaError, never>// ▼const encoding = Schema.encodeEffect(User)({ id: "user-123", name: "John Doe" })Integrations
Section titled “Integrations”Schema integrates with popular frameworks and libraries. This section shows working examples for forms (TanStack Form) and web servers (Elysia).
TanStack Form
Section titled “TanStack Form”Features:
- Errors are formatted with the
StandardSchemaV1formatter. - Fields are validated and parsed (not just strings).
- You can add form-level validation by attaching filters to the struct.
- Schemas may include async transformations.
Example (Parse user input and surface form-level errors)
import { useForm } from "@tanstack/react-form"import type { AnyFieldApi } from "@tanstack/react-form"import { Effect, Schema, SchemaGetter, SchemaTransformation } from "effect"import React from "react"
// ----------------------------------------------------// Toolkit// ----------------------------------------------------
// Treat an empty string from the UI as `undefined` for optional fields,// and encode `undefined` back to an empty string when showing it.const UndefinedFromEmptyString = Schema.Undefined.pipe( Schema.encodeTo(Schema.Literal(""), { decode: SchemaGetter.transform(() => undefined), encode: SchemaGetter.transform(() => "" as const) }))
// Helper to make any schema "UI-optional":// - empty string -> undefined// - otherwise validate/parse with the given schemafunction optional<S extends Schema.Top>(schema: S) { return Schema.Union([UndefinedFromEmptyString, schema])}
// Decode helper that returns a `Promise<Result>` with either a typed value// or a human-friendly error message string.function decode<T, E>(schema: Schema.Codec<T, E>) { return function(value: unknown) { return Schema.decodeUnknownEffect(schema)(value).pipe( Effect.mapError((error) => error.message), Effect.result, Effect.runPromise ) }}
// ----------------------------------------------------// Schemas// ----------------------------------------------------
const FirstName = Schema.String.check( Schema.isMinLength(3, { message: "must be at least 3 characters" }))const Age = Schema.Number.check( Schema.isInt({ message: "must be an integer" }).abort(), Schema.isBetween( { minimum: 18, maximum: 100 }, { message: "must be between 18 and 100" } )).pipe(Schema.encodeTo(Schema.String, SchemaTransformation.numberFromString))
// Whole-form schema with a form-level rule:// If firstName is "John", age is required.const schema = Schema.Struct({ firstName: FirstName, age: optional(Age)}).check( Schema.makeFilter(({ firstName, age }) => { if (firstName === "John" && age === undefined) return "Age is required for John" }))
function FieldInfo({ field }: { field: AnyFieldApi }) { return ( <> {field.state.meta.isTouched && !field.state.meta.isValid ? <em>{field.state.meta.errors.map((error) => error.message).join(", ")}</em> : null} {field.state.meta.isValidating ? "Validating..." : null} </> )}
export default function App() { // We parse the whole form on submit and keep the typed value here const parsedRef = React.useRef<undefined | typeof schema.Type>(undefined)
const form = useForm({ defaultValues: { firstName: "John", age: "" } satisfies (typeof schema)["Encoded"], validators: { onChangeAsync: Schema.toStandardSchemaV1(schema),
// Final guard before submit: // - decode the entire form // - on failure: return a string (form-level error) // - on success: stash the typed value for `onSubmit` onSubmitAsync: async ({ value }) => { const r = await decode(schema)(value) if (r._tag === "Failure") return r.failure parsedRef.current = r.success } },
// Submit runs only if validators pass. // At this point `parsedRef.current` holds the fully typed value. onSubmit: async () => { // get the parsed value from the ref const parsed = parsedRef.current if (!parsed) throw new Error("Unexpected submit without parsed data") // Use the typed data here (no post-processing needed) console.log(parsed) } })
return ( <div> <h1>Simple Form Example</h1> <form onSubmit={(e) => { e.preventDefault() e.stopPropagation() form.handleSubmit() }} > <div> <form.Field name="firstName"> {(field) => { return ( <> <label htmlFor={field.name}>First Name:</label> <input id={field.name} name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} /> <FieldInfo field={field} /> </> ) }} </form.Field> </div>
<div> <form.Field name="age"> {(field) => { return ( <> <label htmlFor={field.name}>Age:</label> <input id={field.name} name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} /> <FieldInfo field={field} /> </> ) }} </form.Field> </div>
<form.Subscribe selector={(s) => [s.errorMap]}> {([errorMap]) => errorMap.onSubmit ? ( <div role="alert" style={{ marginTop: 12 }}> <em>{String(errorMap.onSubmit)}</em> </div> ) : null} </form.Subscribe>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}> {([canSubmit, isSubmitting]) => ( <button type="submit" disabled={!canSubmit}> {isSubmitting ? "..." : "Submit"} </button> )} </form.Subscribe> </form> </div> )}Integrations
Section titled “Integrations”Elysia
Section titled “Elysia”import { node } from "@elysiajs/node"import { openapi } from "@elysiajs/openapi"import { Schema } from "effect"import { Elysia } from "elysia"
// ----------------------------------------------------// Utilities// ----------------------------------------------------
function encodingJsonSchema<T, E, RD>(schema: Schema.Codec<T, E, RD, never>) { return Schema.toStandardSchemaV1( Schema.flip(Schema.toCodecJson(schema)).annotate({ direction: "encoding" }) )}
function decodingJsonSchema<T, E, RE>(schema: Schema.Codec<T, E, never, RE>) { return Schema.toStandardSchemaV1(Schema.toCodecJson(schema))}
function decodingStringSchema<T, E, RE>(schema: Schema.Codec<T, E, never, RE>) { return Schema.toStandardSchemaV1(Schema.toCodecStringTree(schema))}
function mapJsonSchema(schema: Schema.Top) { return Schema.toJsonSchema(schema.ast.annotations?.direction === "encoding" ? Schema.flip(schema) : schema, { target: "draft-2020-12", // or "draft-07" referenceStrategy: "skip" }).schema}
// ----------------------------------------------------// Application// ----------------------------------------------------
new Elysia({ adapter: node() }) .use( openapi({ mapJsonSchema: { effect: mapJsonSchema } }) ) .get( "/id/:id", async ({ status, params, query }) => { console.log(`params: ${JSON.stringify(params)}`) console.log(`query: ${JSON.stringify(query)}`) return status(200, { date: new Date() }) }, { params: decodingStringSchema( Schema.Struct({ id: Schema.Int }) ), query: decodingStringSchema( Schema.Struct({ required: Schema.String, optional: Schema.optionalKey(Schema.String), array: Schema.Array(Schema.String), tuple: Schema.Tuple([Schema.String, Schema.Int]) }) ), response: { 200: encodingJsonSchema( Schema.Struct({ date: Schema.ValidDate }) ) } } ) .post( "/body", ({ body }) => { console.log(body) return { bigint: body.bigint + 1n } }, { body: decodingJsonSchema( Schema.Struct({ bigint: Schema.BigInt }) ), response: { 200: encodingJsonSchema( Schema.Struct({ bigint: Schema.BigInt }) ) } } ) .listen(3000)