Skip to content

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.
  • 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.
  1. Elementary schemas — built-in schemas for primitives, literals, strings, numbers, dates, and template literals.
  2. Composite schemas — combine elementary schemas into structs (objects), tuples, arrays, records, and unions.
  3. Validation — add runtime checks (filters) to constrain values, report multiple errors, and define custom rules.
  4. Constructors — create validated values at runtime, with support for defaults, brands, and refinements.
  5. Transformations — convert values between types during decoding and encoding. Transformations are reusable objects you compose with schemas.
  6. Flipping — swap a schema’s decoding and encoding directions.
  7. Classes and opaque types — create distinct TypeScript types backed by structs, with optional methods and equality.
  8. Serialization — convert values to and from JSON, FormData, URLSearchParams, and XML using canonical codecs.
  9. Tooling — generate JSON Schemas, test data generators (Arbitraries), equivalence checks, optics, and JSON Patch differs from a single schema.
  10. Error handling — format validation errors for display, with hooks for internationalization.
  11. Middlewares — intercept decoding/encoding to provide fallbacks or inject services.
  12. Advanced topics — internal type model and type hierarchy (for library authors).
  13. Integrations — working examples for TanStack Form and Elysia.
  14. Migration from v3 — API mapping from Schema v3 to v4.

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.

Use these schemas when a value should be exactly one of the basic JavaScript types.

import { Schema } from "effect"
// primitive types
Schema.String
Schema.Number
Schema.BigInt
Schema.Boolean
Schema.Symbol
Schema.Undefined
Schema.Null

Sometimes 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"

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.Null
Schema.Undefined
Schema.Void

To 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.members

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())

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())
import { Schema } from "effect"
Schema.Number // all numbers
Schema.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))

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())

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.

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 "@" symbol
const 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"))]))
*/

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]))]))

Once you have elementary schemas, you can combine them into composite schemas that describe objects, arrays, tuples, key-value maps, and unions.

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.

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"]

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.Type

Omitting 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.Type

Representing 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.Type

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.

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 }

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 }

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 } }

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 }

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.

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: {}
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: {}
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 }))
// throws
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 }

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"]
)]))
*/

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"]
)]))
*/

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"})
*/

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 numbers
export 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.Encoded

If 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 numbers
export 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.Encoded

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.

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
})

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 succinctly
const 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"})]))

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)
})
)

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))

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))

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"
})
)

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)]
})
)

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)
})
)

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` field
const 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 value
const literal = tagged.fields._tag.schema.literal
// literal: "A"

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.

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.Encoded

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]
)]))
*/

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]))

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 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)
])
)

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))

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)
)

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
])
)

An array schema describes a variable-length list where every element shares the same type.

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"])]))

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".

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 }

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"]))]))

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.Encoded

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.Type

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.Type

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.Type

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.

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)]))

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})]))

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.

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 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)
])
)

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))

You can create a union of literals using Schema.Literals.

import { Schema } from "effect"
const schema = Schema.Literals(["red", "green", "blue"])

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"]

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 structs
const 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.

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 utilities
const 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.

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.A
const B = tagged.cases.B
const C = tagged.cases.C

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" })) // true
console.log(tagged.isAnyOf(["A", "B"])({ type: "B", b: 1 })) // true
console.log(tagged.isAnyOf(["A", "B"])({ type: "C", c: true })) // false

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" })) // true
console.log(tagged.guards.B({ type: "B", b: 1 })) // true
console.log(tagged.guards.A({ type: "B", b: 1 })) // false

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: a
console.log(matcher({ type: "B", b: 1 })) // This is a B: 1
console.log(matcher({ type: "C", c: true })) // This is a C: true

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.

When none of the built-in schema combinators fit your data type, use Schema.declare or Schema.declareConstructor.

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 instance
const 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 instanceof checks, prefer Schema.instanceOf(URL), it wraps Schema.declare with an instanceof guard 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>"

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 schema
const codec = Schema.toCodecJson(URLSchema)
// Encoding a URL produces null because Schema doesn't know
// how to serialize a URL to JSON yet
console.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:

  1. A JSON-side schema — the shape of the JSON value (e.g. Schema.String for a URL string)
  2. 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 string
console.log(String(Schema.encodeUnknownExit(codec)(new URL("https://example.com"))))
// Success("https://example.com/")
// And decoding parses a string back into a URL
console.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: declareConstructor is for types where the container shape is the same on both sides: only the inner type parameter changes (e.g. Box<Encoded> to Box<Type>). If you need to convert a structurally different type into your declared type (e.g. T to Box<T>), first declare Box with declareConstructor, then define a separate transformation schema to express the conversion.

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)
)
  1. Outer call declareConstructor<Type, Encoded>() — fixes the TypeScript types. Type is the decoded type, Encoded is the encoded type.
  2. Inner call (typeParameters, run, annotations) — provides the runtime behavior:
    • typeParameters — an array of schemas, one for each type variable (e.g. [itemSchema] for Box<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 like expected, toCodecJson, etc.

The parsing function you return from run is responsible for:

  1. Checking that the input has the right shape (e.g. is an object with a value property)
  2. Recursively decoding inner values using the provided codecs
  3. Returning an Effect that 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 type
interface 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 numbers
const 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"]))]))

declareConstructor accepts the same annotations as declare — including expected (for custom error messages) and toCodecJson (for JSON serialization). See the Schema.declare section above for details on how to use them.

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 characters
const 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 message
const 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 identifier is 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 message annotation is used first, then its expected annotation, 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 "")]))

A filter predicate can return any of the shapes described by Schema.FilterOutput:

  • undefined or true — 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 for Composite, AnyOf, etc.).
  • { path, issue } — failure attached to a nested path. issue can be a string (wrapped in an InvalidValue) or a full SchemaIssue.Issue.
  • ReadonlyArray<FilterIssue> — several failures reported together. Empty arrays are success; a single element is unwrapped; multiple entries are grouped into an Issue.Composite.

Example (Failure at a nested path)

import { Schema } from "effect"
const schema = Schema.Struct({ password: Schema.String, confirmPassword: Schema.String }).check(
Schema.makeFilter((o) =>
o.password === o.confirmPassword
? undefined
: { path: ["password"], issue: "password and confirmPassword must match" }
)
)
console.log(String(Schema.decodeUnknownExit(schema)({ password: "123456", confirmPassword: "1234567" })))
// Failure(Cause([Fail(SchemaError: password and confirmPassword must match
// at ["password"])]))

Example (Reporting multiple failures at once)

import { Schema } from "effect"
const schema = Schema.Struct({ a: Schema.Finite, b: Schema.Finite, c: Schema.Finite }).check(
Schema.makeFilter((o) => {
const issues: Array<Schema.FilterIssue> = []
if (o.a > 0) {
if (o.b <= 0) issues.push({ path: ["b"], issue: "b must be greater than 0" })
if (o.c <= 0) issues.push({ path: ["c"], issue: "c must be greater than 0" })
}
return issues
})
)
console.log(String(Schema.decodeUnknownExit(schema)({ a: 1, b: 0, c: 0 })))
// Failure(Cause([Fail(SchemaError: b must be greater than 0
// at ["b"]
// c must be greater than 0
// at ["c"])]))

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) filter
const schema = Schema.Struct({
name: Schema.String,
age: Schema.Number
}).check(Schema.makeFilter(() => true))
// The `.fields` property is still available
const fields = schema.fields

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 >= 3
const 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 strings
const 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"]]))

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")]))
*/

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")]))

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"
}
)

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)
)

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"))

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:

  • isMinLength or isMaxLength on arrays
  • isMinSize or isMaxSize on objects with a size property
  • isMinProperties or isMaxProperties on 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"])]))
*/

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 0
const 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()
})
)

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 value
export 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
})
}
}

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 } }

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 })

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.make

However, 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.make

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.make

As 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.make

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 }

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 } }

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 value
class 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 convert values from one type to another during decoding or encoding. They are standalone, reusable objects you compose with schemas.

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"))
// 123

A Transformation carries four type parameters:

Transformation<T, E, RD, RE>
  • T: the decoded (output) type
  • E: the encoded (input) type
  • RD: the context used while decoding
  • RE: the context used while encoding

A Transformation consists of two Getter functions:

  • decode: Getter<T, E, RD> — transforms a value during decoding
  • encode: 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 decode process uses Getter.trim() to remove leading and trailing whitespace.
  • The encode process uses Getter.passthrough(), which returns the input as is.

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 toLowerCase
const trimToLowerCase = SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase())
// Run the decode logic manually to inspect the result
console.log(trimToLowerCase.decode.run(Option.some(" Abc"), {}))
/*
{
_id: 'Exit',
_tag: 'Success',
value: { _id: 'Option', _tag: 'Some', value: 'abc' }
}
*/

In this example:

  • The decode logic applies Getter.trim() followed by Getter.toLowerCase(), producing a string that is trimmed and lowercased.
  • The encode logic is Getter.passthrough(), which simply returns the input as-is.

To define how one schema transforms into another, you can use:

  • Schema.decodeTo (and its inverse Schema.encodeTo)
  • Schema.decode (and its inverse Schema.encode)

These functions let you attach transformations to schemas, defining how values should be converted during decoding or encoding.

Use Schema.decodeTo when you want to transform a source schema into a different target schema.

You must provide:

  1. The target schema
  2. 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"))
// 123
console.log(Schema.decodeUnknownSync(NumberFromString)("a"))
// NaN

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()))

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)
})
)
)

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.

The passthrough, passthroughSubtype, and passthroughSupertype helpers let you compose schemas by describing how their types relate.

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()))

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()))

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()))

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 })))

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:

  • E is the encoded type
  • T is 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 output
  • Option.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" }

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 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.schema

Flipping 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))

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 version
encode(schema) = decode(flip(schema))

This symmetry ensures that flipping works consistently across all schema types.

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).make

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.

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.

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 implement Equal.
  • Instances carry the class prototype at runtime, so instanceof checks 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: Person
const person = Person.make({ name: "John" })
console.log(person.name)
// "John"
// The class itself holds the original schema and its metadata
console.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 struct
const 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)

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' }

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 type

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"]

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) => a
const g = (b: B) => b
f(A.make({ a: "a" })) // ok
g(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) => a
const g = (b: B) => b
f(A.make({ a: "a" })) // ok
g(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.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.

import { Schema } from "effect"
class MyString extends Schema.asClass(Schema.String) {
static readonly decodeUnknownSync = Schema.decodeUnknownSync(this)
}
console.log(MyString.decodeUnknownSync("a"))
// "a"
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" }

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"))
// 1
console.log(MyString2.encodeSync(1))
// "1"

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 class
class 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)
}
}
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...

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 })

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 }

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"}

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 parameter
class 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-error
export const a: A = B.make({ a: "a" })
// @ts-expect-error
export 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-error
export const a: A = B.make({ a: "a" })
// @ts-expect-error
export const b: B = A.make({ a: "a" })

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"

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 }

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"

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 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.

import { Schema } from "effect"
class E extends Schema.ErrorClass<E>("E")({
id: Schema.Number
}) {}

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 tag
const 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 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.

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 }

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 }

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.

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 struct
const schemaFromBase64 = Schema.StringFromBase64.pipe(
Schema.decodeTo(Schema.fromJsonString(schema))
)

Like StringFromBase64, but uses the URL-safe Base64 alphabet (RFC 4648 section 5).

import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.StringFromBase64Url)("aGVsbG8")
// => "hello"

Decodes a hex-encoded string into a UTF-8 string.

import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.StringFromHex)("68656c6c6f")
// => "hello"

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%7D

For binary data, use the Uint8Array variants instead:

  • Schema.Uint8ArrayFromBase64 - decodes Base64 into a Uint8Array.
  • Schema.Uint8ArrayFromBase64Url - decodes URL-safe Base64 into a Uint8Array.
  • Schema.Uint8ArrayFromHex - decodes hex into a Uint8Array.

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.

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:

  1. Parse FormData into a nested tree record.
  2. 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 number

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:

  1. Parse URLSearchParams into a nested tree record.
  2. 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 number

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.

Many JavaScript values cannot be serialized to JSON in a safe and reversible way:

  • Date: JSON.stringify() converts a date to an ISO string, but JSON.parse() does not restore a Date object
  • Uint8Array, ReadonlyMap, ReadonlySet: JSON.stringify() converts them to {}, so the original data is lost
  • Symbol, 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:

  1. Annotation-based: you choose a serialization strategy by adding annotations to your schema (for example toCodecJson, toCodecIso, toCodecStringTree, and others).
  2. 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).
  3. 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: {} }

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.

When you call Schema.toCodecJson(schema), the library:

  1. Walks the AST: it traverses the schema’s abstract syntax tree (AST) recursively. For details, see the SchemaAST module.
  2. Finds annotations: it looks for toCodecJson annotations on nodes.
  3. Applies transformations: it replaces types that are not JSON-friendly with types that are.
  4. Composes recursively: it builds codecs for nested schemas by combining the codecs of their parts.

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 format

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 StringTree values
  • arrays of StringTree values

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 numbers
console.log(json)
// [1, 2]
const toCodecStringTree = Schema.toCodecStringTree(PointSchema)
const stringTree = Schema.encodeUnknownSync(toCodecStringTree)(point)
// every leaf value becomes a string
console.log(stringTree)
// [ '1', '2' ]

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 schema
class 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.

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 can derive JSON Schemas, test data generators (Arbitraries), equivalence checks, optics, and more from a single schema definition.

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 Schema
const 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": {}
}
*/

Use .annotate(...) to attach standard JSON Schema annotations:

  • title
  • description
  • default
  • examples
  • readOnly
  • writeOnly

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 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"]])})

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": {}
}
*/

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": {}
}
*/

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.

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.

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.value
result.report.warnings

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

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

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, minLength and maxLength mean string length.
  • On arrays, minLength and maxLength mean array length.
  • On objects, minLength and maxLength mean final own-property count.
  • On sets, maps, hash collections, and chunks, minLength and maxLength mean final collection size.
  • patterns apply to string generation.
  • integer, noNaN, noInfinity, valid, and unique are enabled when any contributing filter sets them.
  • ordered stores bounds for ordered values such as numbers, bigints, dates, DateTime, and BigDecimal.

Fields that do not make sense for the current node are ignored. The final filter check still validates every generated value.

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.

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 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.

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)

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)

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)

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.

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 classes
class 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 object
const 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' } }

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 data
const schema = Schema.Struct({
id: Schema.Number,
name: Schema.String,
price: Schema.Number
})
// Build a differ tied to the schema
const differ = Schema.toDifferJsonPatch(schema)
// Prepare two values to compare
const 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 value
const patched = differ.patch(oldValue, jsonPatch)
console.log(patched)
// { id: 1, name: 'b', price: 2 }

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 } }

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)

    1. Encode oldValue: T and newValue: T to JSON with the schema serializer.
    2. Compute a JSON Patch document between the two JSON values.
    3. Return that patch (an array of "add" | "remove" | "replace" operations).
  • patch(oldValue, patch)

    1. Encode oldValue: T to JSON.
    2. Apply the JSON Patch to the JSON value.
    3. Decode the patched JSON back to T using the schema.

This approach keeps patches independent from TypeScript types and uses the schema as the guardrail when turning JSON back into T.

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 / fromASTs turn a schema AST into a Document / MultiDocument
  • DocumentFromJson (schema) round-trip that document through JSON
  • toSchema rebuilds a runtime Schema from the stored representation
  • toJsonSchemaDocument produces a Draft 2020-12 JSON Schema document
  • toCodeDocument prepares data for code generation (via toMultiDocument)
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)"|JSON

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.

A Document has:

  • representation: the root Representation
  • references: a map of named definitions used by the root representation

References let the representation share definitions and support recursion.

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.

SchemaRepresentation is meant for schemas that can be described without user code.

That has a few consequences.

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.

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 stored as a record, but:

  • only values that look like JSON primitives (plus bigint and symbol in 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.

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, ReadonlySet
  • RegExp, URL, Date
  • FormData, URLSearchParams, Uint8Array
  • DateTime.Utc, effect/Duration

If your document contains other declarations, pass a custom reviver to toSchema.

  • toJson(document) returns JSON-compatible data (safe to JSON.stringify)
  • fromJson(unknown) validates and parses JSON data back into a Document

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.

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
}
})

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).

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:

  • sanitizeReference to control how $ref strings become identifiers
  • reviver to generate custom code for Declaration nodes

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.

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 message or messageMissingKey or messageUnexpectedKey

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:

  • InvalidType
  • InvalidValue
  • MissingKey
  • UnexpectedKey
  • Forbidden
  • OneOf

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 ""' }
]
}
*/

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

utils.ts
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 rendered
const 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" key
logIssues(Person, {})
// Failure(Cause([Fail([{"path":["name"],"message":"This field is required"}])]))
// "name" has the wrong type
logIssues(Person, { name: 1 })
// Failure(Cause([Fail([{"path":["name"],"message":"Please enter a valid string"}])]))
// "name" is an empty string
logIssues(Person, { name: "" })
// Failure(Cause([Fail([{"path":["name"],"message":"Please enter at least 1 character(s)"}])]))

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 hooks
const logIssues = getLogIssues()
// Invalid object (not even a struct)
logIssues(Person, null)
// Failure(Cause([Fail([{"path":[],"message":"Please enter a valid object"}])]))
// Missing "name" key
logIssues(Person, {})
// Failure(Cause([Fail([{"path":["name"],"message":"This field is required"}])]))
// "name" has the wrong type
logIssues(Person, { name: 1 })
// Failure(Cause([Fail([{"path":["name"],"message":"Please enter a valid string"}])]))
// "name" is an empty string
logIssues(Person, { name: "" })
// Failure(Cause([Fail([{"path":["name"],"message":"Please enter at least 1 character(s)"}])]))

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)' ] }
]
}
*/

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.

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 succeed
const 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 succeed
const 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 value
class 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")

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"]
}
  • T: the decoded output type
  • E: the encoded representation
  • RD: the type of the services required for decoding
  • RE: the type of the services required for encoding
  • Ast: the AST node type
  • RebuildOut: the type returned when modifying the schema (namely when you add annotations or checks)
  • TypeMakeIn: the type of the input to the make constructor
  • Iso: the type of the focus of the default Optic.Iso
  • TypeParameters: 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 value
  • TypeReadonly: whether the schema is readonly on the type side
  • TypeIsOptional: whether the schema is optional on the type side
  • TypeDefault: whether the constructor has a default value
  • EncodedIsReadonly: whether the schema is readonly on the encoded side
  • EncodedIsOptional: whether the schema is optional on the encoded side

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 node
  • checks: an array of validation rules
  • encoding: a list of transformations that describe how to encode the value
  • context: includes details used when the schema appears inside composite schemas such as structs or tuples (e.g., whether the field is optional or mutable)

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 shape
  • Schema<T>: represents the TypeScript type T
  • Codec<T, E, RD, RE>: a schema that decodes E to T and encodes T to E, possibly requiring services RD and RE
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]"]

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.FiniteFromString

These wide types reset other internal parameters to defaults, which removes useful information:

  • Top: all type parameters are set to defaults
  • Schema: all type parameters except Type are set to defaults
  • Codec: all type parameters except Type, Encoded, DecodingServices, EncodingServices are set to defaults

Example (How wide annotations erase information)

import { Schema } from "effect"
// Read a hidden type-level property from a concrete schema
type TypeMutability = (typeof Schema.FiniteFromString)["~type.mutability"] // "readonly"
const schema: Schema.Codec<number, string> = Schema.FiniteFromString
// After widening to Codec<...>, the mutability info is broadened
type TypeMutability2 = (typeof schema)["~type.mutability"] // "readonly" | "mutable"

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` annotation
declare 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 compiler
const schema = Schema.String.annotate({ version: [1, 2, 0] })
// const version: readonly [major: number, minor: number, patch: number] | undefined
const version = Schema.resolveAnnotations(schema)?.["version"]
if (version) {
// Access individual parts of the version
console.log(version[1])
// Output: 2
}

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" }

Using generics in schema composition and filters can be difficult.

The plan is to make generics covariant and easier to use.

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 decoding
  • RE: 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 ID
class 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 ID
declare 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" })

Schema integrates with popular frameworks and libraries. This section shows working examples for forms (TanStack Form) and web servers (Elysia).

Features:

  • Errors are formatted with the StandardSchemaV1 formatter.
  • 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 schema
function 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>
)
}
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)