Schema Internals
How Effect’s Schema system works under the hood: AST structure, filters, transformations, JSON Schema generation, Standard Schema interop, and error formatting.
Struct and Class Schemas
Schema.Struct defines object schemas. Schema.Class extends this with a TypeScript class, giving you both runtime validation and a constructor type.
import { Effect, Schema } from "effect"
const UserSchema = Schema.Struct({
id: Schema.Int,
name: Schema.NonEmptyString,
email: Schema.String,
role: Schema.Literals(["admin", "member"])
})
export class User extends Schema.Class<User>("User")({
id: Schema.Int,
name: Schema.NonEmptyString,
email: Schema.String,
role: Schema.Literals(["admin", "member"])
}) {}
Every schema has two type projections: Type (the decoded value) and Encoded (the external representation). For most schemas these are identical, but transformations make them diverge.
type DecodedUser = typeof User["Type"] // User instance
type EncodedUser = typeof User["Encoded"] // { id: number; name: string; ... }
Decode and Encode Patterns
const decodeUser = Schema.decodeUnknownEffect(User)
const encodeUser = Schema.encodeEffect(User)
const encodeJson = Schema.encodeJsonSync(User)
const program = Effect.gen(function*() {
const user = yield* decodeUser({ id: 1, name: "Alice", email: "a@b.com", role: "admin" })
return encodeJson(user)
})
Tip: Reuse parsers at the edges of your application. Build them once, call them per request. The
decodeUnknownEffectvariant keeps errors in the Effect error channel for typed error handling.
v4 renamed several decode/encode APIs:
| v3 | v4 |
|---|---|
Schema.decodeUnknown | Schema.decodeUnknownEffect |
Schema.decode | Schema.decodeEffect |
Schema.decodeUnknownEither | Schema.decodeUnknownExit |
Schema.encode | Schema.encodeEffect |
Schema.validateSync | Schema.decodeSync(Schema.toType(...)) |
Filters and Checks
v4 replaces Schema.filter with Schema.check(Schema.makeFilter(...)) and Schema.refine for type-narrowing predicates.
import { Schema } from "effect"
const NonEmpty = Schema.String.check(Schema.makeFilter((s) => s.length > 0))
const PositiveInt = Schema.Int.check(Schema.makeFilter((n) => n > 0))
A makeFilter predicate can return several shapes:
undefined/true- successfalse- generic failurestring- failure with that messageSchemaIssue.Issue- a fully-formed issue{ path, issue }- failure at a nested pathReadonlyArray<Schema.FilterIssue>- multiple failures at once
const PasswordMatch = Schema.Struct({
password: Schema.String,
confirmPassword: Schema.String
}).check(
Schema.makeFilter((o) =>
o.password === o.confirmPassword
? undefined
: { path: ["password"], issue: "passwords must match" }
)
)
Use Schema.refine when the filter narrows the type:
import { Option, Schema } from "effect"
const SomeString = Schema.Option(Schema.String).pipe(
Schema.refine(Option.isSome)
)
Built-in Checks
v4 renamed built-in filters with an is prefix: minLength is now isMinLength, greaterThan is now isGreaterThan, int is now isInt, and so on. Use them via Schema.String.check(Schema.isMinLength(3)) or Schema.Number.check(Schema.isGreaterThan(0)).
Transformations
Transformations convert between encoded and decoded representations. v4 uses Schema.decodeTo with SchemaTransformation.transform instead of the v3 Schema.transform function.
import { Schema, SchemaTransformation } from "effect"
const BooleanFromString = Schema.Literals(["on", "off"]).pipe(
Schema.decodeTo(
Schema.Boolean,
SchemaTransformation.transform({
decode: (literal) => literal === "on",
encode: (bool) => (bool ? "on" : "off")
})
)
)
For transformations that can fail, use SchemaGetter.transformOrFail:
import { Effect, Schema, SchemaGetter, SchemaIssue } from "effect"
const NumberFromString = Schema.String.pipe(
Schema.decodeTo(Schema.Number, {
decode: SchemaGetter.transformOrFail((s) => {
const n = Number.parse(s)
if (n === undefined) {
return Effect.fail(new SchemaIssue.InvalidValue())
}
return Effect.succeed(n)
}),
encode: SchemaGetter.String()
})
)
Gotcha: In v4,
Schema.transformis a method on schemas, not a top-level function. The top-levelSchema.transform(from, to, { decode, encode })is nowfrom.pipe(Schema.decodeTo(to, SchemaTransformation.transform({ decode, encode }))).
Annotations
Annotations attach metadata to schemas without changing validation behavior. Use them for documentation, custom messages, and JSON Schema generation hints.
import { Schema } from "effect"
const Email = Schema.String.pipe(
Schema.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
).annotate({
description: "A valid email address",
message: () => "Expected an email address"
})
v4 renamed annotations(ann) to annotate(ann). The message annotation takes precedence over formatter hooks in error output.
JSON Schema Generation
Effect schemas can generate JSON Schema Draft 2020-12 documents.
import { Schema, SchemaRepresentation } from "effect"
const UserSchema = Schema.Struct({
id: Schema.Int,
name: Schema.String,
email: Schema.String
})
const doc = Schema.toJsonSchemaDocument(UserSchema)
For lower-level control, use SchemaRepresentation.toJsonSchemaDocument(document) to compile a live representation document directly. You can also import JSON Schema back into Effect schemas via SchemaRepresentation.fromJsonSchemaDocument.
Gotcha: Compiler callbacks are not persisted. Compile the live document before calling
toJson, or rebuild and lower the schema with revivers first.
Standard Schema Interop
Effect Schema implements the Standard Schema spec, a community standard for schema validation libraries.
import { Schema } from "effect"
const UserSchema = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const standard = Schema.toStandardSchemaV1(UserSchema)
const result = standard["~standard"].validate({ name: "Alice", age: 30 })
if (result.issues) {
for (const issue of result.issues) {
console.log(issue.path, issue.message)
}
}
This lets you pass Effect schemas to any library that accepts Standard Schema inputs, like TanStack Form:
import { Schema } from "effect"
const formSchema = Schema.Struct({ email: Schema.String })
form({
defaultValues: { email: "" },
validators: {
onChangeAsync: Schema.toStandardSchemaV1(formSchema)
}
})
Tip: For JSON API boundaries, wrap with
Schema.toStandardSchemaV1(Schema.toCodecJson(schema))to validate against the JSON-encoded form rather than the decoded type.
Error Formatters
v4 replaces v3’s ParseResult with SchemaIssue and SchemaError. When validation fails, Schema.SchemaError contains a nested SchemaIssue in its issue field.
import { Schema, SchemaIssue } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
try {
Schema.decodeUnknownSync(Person)({})
} catch (error) {
if (error instanceof Error && SchemaIssue.isIssue(error.cause)) {
const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(error.cause)
console.log(formatted.issues)
}
}
The Standard Schema V1 formatter produces { issues: Array<{ path: PropertyKey[], message: string }> }. You can customize messages via formatter hooks (LeafHook, CheckHook) or per-schema message annotations.
Gotcha: Formatter hooks are required in v4. The default implementation is for demo purposes only. Define your own hooks for production to control message wording and bundle size.
Custom Error Messages
import { Schema, SchemaIssue } from "effect"
const formatter = SchemaIssue.makeFormatterStandardSchemaV1({
leafHook: (issue) => {
switch (issue._tag) {
case "MissingKey":
return `Field "${issue.path[0]}" is required`
case "InvalidType":
return `Expected ${issue.expected}`
default:
return issue._tag
}
},
checkHook: (issue) => issue.message ?? "Validation failed"
})