Skip to content

Issues & Error Formatting

When decoding or encoding fails, Schema does not throw away the details. It produces a SchemaIssue.Issue — a recursive tree that records what went wrong and where in the input. You can render it as a human-readable string, walk it to build custom output, or convert it to a Standard Schema failure result.

import { Schema, Result } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// `decodeUnknownResult` returns Result<Person, SchemaIssue.Issue> — the failure
// channel carries the issue tree directly (not a SchemaError wrapper), as data
// rather than an exception. `{ errors: "all" }` collects every problem at once.
const result = Schema.decodeUnknownResult(Person, { errors: "all" })({
name: 42
})
if (Result.isFailure(result)) {
// result.failure IS the SchemaIssue.Issue tree; String(...) renders it.
console.error(String(result.failure))
}

How you reach the issue depends on the runner you chose in basic usage:

  • decodeUnknownResult — the failure channel carries the SchemaIssue.Issue tree directly (no wrapper); result.failure is the issue.
  • decodeUnknownEffect — the Effect fails with a Schema.SchemaError in its error channel; read .issue for the tree.
  • decodeUnknownExit — the Exit fails with a Schema.SchemaError (Exit.Exit<A, Schema.SchemaError>); read .issue for the tree.
  • decodeUnknownSync — throws a plain Error whose message is the formatted string (issue.toString()) and whose cause holds the SchemaIssue.Issue tree.
  • decodeUnknownOption — collapses the failure to Option.none(); the issue is discarded.
import { Schema, SchemaIssue } from "effect"
const decode = Schema.decodeUnknownSync(Schema.Number)
try {
decode("not a number")
} catch (err) {
if (err instanceof Error) {
// err.message is the formatted string; err.cause is the structured tree.
console.error(err.message)
// Expected number, got "not a number"
console.log(SchemaIssue.isIssue(err.cause)) // => true
}
}

The Result runner does not wrap anything — result.failure is the SchemaIssue.Issue itself, so its _tag is the issue tag (e.g. InvalidType), and SchemaIssue.isIssue returns true for it.

import { Schema, SchemaIssue } from "effect"
const result = Schema.decodeUnknownResult(Schema.Number)("oops")
if (result._tag === "Failure") {
const issue = result.failure
console.log(issue._tag) // => "InvalidType"
console.log(SchemaIssue.isIssue(issue)) // => true
}

The errors field of ParseOptions decides how much of the tree gets built. The default "first" aborts at the first problem; "all" keeps validating siblings and aggregates every failure under a Composite node.

import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
// "first" (default): stops at the first failing field.
const first = Schema.decodeUnknownResult(Person)({ name: 1, age: "x" })
// => Failure with a single MissingKey/InvalidType issue
// "all": collects every failing field into one Composite tree.
const all = Schema.decodeUnknownResult(Person, { errors: "all" })({
name: 1,
age: "x"
})
// => Failure aggregating both field errors

Calling String(issue) (or issue.toString()) uses the default formatter. It renders each leaf failure with its message and the property path at which it occurred.

import { Schema, Effect } from "effect"
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
const program = Schema.decodeUnknownEffect(Person, { errors: "all" })({}).pipe(
// The error channel holds a SchemaError; format its issue for logging.
Effect.catch((err) => Effect.logError(String(err.issue)))
)

When the input is missing both keys with { errors: "all" }, the formatted output names each failing path:

Missing key
at ["name"]
Missing key
at ["age"]

For programmatic handling — mapping failures to your own error type, grouping by field, building a localized message — pattern-match on the issue’s _tag. The tree is a discriminated union; the terminal leaf nodes are the ones you usually care about.

import { SchemaIssue } from "effect"
// Describe an issue in your own words by matching on its tag.
function describe(issue: SchemaIssue.Issue): string {
switch (issue._tag) {
case "MissingKey":
return "a required field is missing"
case "InvalidType":
return "a field has the wrong type"
case "InvalidValue":
return "a field failed a constraint"
default:
// Composite nodes (Pointer, Composite, Filter, ...) wrap inner issues.
return String(issue)
}
}
void describe // illustrative

The composite nodes — Pointer, Composite, Filter, Encoding, AnyOf — wrap inner issues to add context such as a path segment or the filter that failed. SchemaIssue.getActual(issue) extracts the offending input value where one is available.

To integrate with tooling that speaks the Standard Schema spec — form libraries, validators — build a formatter that produces a FailureResult of { message, path } entries instead of a string.

import { Schema, SchemaIssue, Result } from "effect"
const Login = Schema.Struct({
username: Schema.String.check(Schema.isNonEmpty()),
password: Schema.String.check(Schema.isMinLength(8))
})
const formatter = SchemaIssue.makeFormatterStandardSchemaV1()
const result = Schema.decodeUnknownResult(Login, { errors: "all" })({
username: "",
password: "short"
})
if (Result.isFailure(result)) {
// result.failure is the SchemaIssue.Issue; format it directly.
// { issues: [{ message, path }, ...] } — one entry per failing field.
console.error(formatter(result.failure).issues)
}

Or skip the manual decode and hand the schema directly to a Standard Schema consumer with Schema.toStandardSchemaV1.


SchemaIssue.Issue is a _tag-discriminated union. Leaf nodes are terminal; composite nodes wrap one or more inner issues to add context. Every node has a toString() that delegates to the default formatter.

TagKindProduced when
InvalidTypeleafruntime type mismatch (e.g. string expected, got null)
InvalidValueleafright type, wrong value (failed value constraint)
MissingKeyleafa required struct key / tuple index is absent
UnexpectedKeyleafan extra key is present under strict validation
Forbiddenleafa forbidden operation (e.g. async work in a sync runner)
OneOfleafa oneOf union matched more than one member
Filtercompositea refinement check (Schema.makeFilter / .check) rejected the value
Encodingcompositea transformation (decodeTo / encodeTo) failed
Pointercompositeadds a property-key path to an inner issue
Compositecompositeaggregates sibling issues under one schema node
AnyOfcompositea value matched no member of a union

The runtime type of the input does not match the schema. actual is Option<unknown> (None when no value was provided). Default message: Expected <type>, got <actual>.

import { Schema } from "effect"
const r = Schema.decodeUnknownResult(Schema.String)(42)
if (r._tag === "Failure") {
console.log(r.failure._tag) // => "InvalidType"
console.log(String(r.failure)) // => "Expected string, got 42"
}

The input has the correct type but violates a value constraint. actual is Option<unknown>; an optional annotations.message overrides the default Invalid data <actual>.

import { Option, SchemaIssue } from "effect"
const issue = new SchemaIssue.InvalidValue(Option.some(""), {
message: "must not be empty"
})
console.log(String(issue)) // => "must not be empty"

A required struct key or tuple index is absent. Carries no actual value; annotations may hold a custom messageMissingKey. Default message: Missing key.

import { SchemaIssue } from "effect"
const issue = new SchemaIssue.MissingKey(undefined)
console.log(String(issue)) // => "Missing key"

An object/tuple contains a key the schema did not declare (strict validation). actual is the raw value at that key; ast is the schema. Default message: Unexpected key with value <actual>.

import { SchemaIssue } from "effect"
// `ast` comes from a real schema node; matched here by `_tag`.
function isExtra(issue: SchemaIssue.Issue): boolean {
return issue._tag === "UnexpectedKey"
}
void isExtra

A forbidden operation was hit during parsing — most commonly an asynchronous Effect running inside a synchronous runner like decodeUnknownSync. Default message: Forbidden operation.

import { Option, SchemaIssue } from "effect"
const issue = new SchemaIssue.Forbidden(Option.none(), {
message: "async not allowed in sync context"
})
console.log(String(issue)) // => "async not allowed in sync context"

A value matched more than one member of a union configured for exactly-one matching. successes lists the AST nodes that accepted it. Default message: Expected exactly one member to match the input <actual>.

import { SchemaIssue } from "effect"
function isAmbiguous(issue: SchemaIssue.Issue): boolean {
return issue._tag === "OneOf"
}
void isAmbiguous

A refinement check (Schema.makeFilter, .check(...)) rejected the value. actual is the tested value, filter is the AST check node, and issue is the inner failure reason.

import { SchemaIssue } from "effect"
function describeFilter(issue: SchemaIssue.Issue): string {
if (issue._tag === "Filter") {
return `Filter failed on: ${JSON.stringify(issue.actual)}`
}
return String(issue)
}
void describeFilter

See filters for the checks that produce Filter issues.

A transformation step (Schema.decodeTo / Schema.encodeTo) failed. ast is the transformation node, actual is Option<unknown>, and issue is the inner failure. Formatters recurse straight into issue.

import { Schema, SchemaIssue } from "effect"
// NumberFromString decodes "abc" -> fails inside the transform.
const r = Schema.decodeUnknownResult(Schema.NumberFromString)("abc")
if (r._tag === "Failure") {
console.log(SchemaIssue.isIssue(r.failure)) // => true
}

Wraps an inner issue with a path (array of property keys) marking where the error occurred. Carries no actual. Formatters concatenate nested pointer paths into one path like ["a"]["b"][0].

import { SchemaIssue } from "effect"
const inner = new SchemaIssue.MissingKey(undefined)
const issue = new SchemaIssue.Pointer(["age"], inner)
console.log(String(issue))
// => "Missing key\n at [\"age\"]"

Groups multiple child issues (non-empty) under one schema ast node — this is what errors: "all" builds for structs and tuples. actual is Option<unknown>. Formatters flatten it by recursing into each child.

import { Schema } from "effect"
const Point = Schema.Struct({ x: Schema.Number, y: Schema.Number })
const r = Schema.decodeUnknownResult(Point, { errors: "all" })({
x: "a",
y: "b"
})
if (r._tag === "Failure") {
console.log(r.failure._tag) // => "Composite"
}

A value matched no member of a union. issues holds per-member failures; when empty, the formatter falls back to the union’s expected annotation.

import { Schema } from "effect"
const U = Schema.Union([Schema.String, Schema.Number])
const r = Schema.decodeUnknownResult(U)(true)
if (r._tag === "Failure") {
console.log(r.failure._tag) // => "AnyOf"
}

Type guard narrowing unknown to Issue, useful in catch-all handlers.

import { SchemaIssue } from "effect"
const issue = new SchemaIssue.MissingKey(undefined)
console.log(SchemaIssue.isIssue(issue)) // => true
console.log(SchemaIssue.isIssue("nope")) // => false

Returns the offending input as Option<unknown>, normalizing across variants. Returns None for Pointer and MissingKey (they carry no value), the stored Option for InvalidType / InvalidValue / Forbidden / Encoding / Composite, and Option.some(actual) for AnyOf / UnexpectedKey / OneOf / Filter.

import { Option, SchemaIssue } from "effect"
const a = SchemaIssue.getActual(new SchemaIssue.MissingKey(undefined))
console.log(a) // => { _tag: "None" }
const b = SchemaIssue.getActual(
new SchemaIssue.InvalidValue(Option.some(""))
)
console.log(b) // => { _tag: "Some", value: "" }

A SchemaIssue.Formatter<Format> is a function Issue => Format. The module ships two factories plus the customizable hooks they build on.

Creates a Formatter<string> that flattens the tree into { message, path } entries and renders each as <message> or <message>\n at <path>, joined by newlines. This is the formatter behind Issue.toString().

import { Schema, SchemaIssue } from "effect"
const format = SchemaIssue.makeFormatterDefault()
const r = Schema.decodeUnknownResult(Schema.String)(1)
if (r._tag === "Failure") {
console.log(format(r.failure)) // => "Expected string, got 1"
}

Creates a Formatter<StandardSchemaV1.FailureResult> producing { issues: [{ message, path }] }. Pointer paths are accumulated into full property paths. Optionally accepts leafHook / checkHook to customize messages.

import { SchemaIssue } from "effect"
const format = SchemaIssue.makeFormatterStandardSchemaV1()
const issue = new SchemaIssue.Pointer(
["name"],
new SchemaIssue.MissingKey(undefined)
)
console.log(format(issue))
// => { issues: [{ message: "Missing key", path: ["name"] }] }

The shared Formatter<string> instance (the result of makeFormatterDefault()) that Issue.toString() delegates to.

import { Option, SchemaIssue } from "effect"
const issue = new SchemaIssue.InvalidValue(Option.some(3))
console.log(SchemaIssue.defaultFormatter(issue)) // => "Invalid data 3"

The built-in LeafHook: maps a terminal issue to a string, honoring a message annotation when present, otherwise the per-_tag default (Expected <type>, got <actual>, Invalid data <actual>, Missing key, etc.). Reuse it as a base for custom hooks.

import { Option, SchemaIssue } from "effect"
const msg = SchemaIssue.defaultLeafHook(
new SchemaIssue.InvalidValue(Option.some(""))
)
console.log(msg) // => "Invalid data \"\""

The built-in CheckHook for Filter issues: returns the inner issue’s message annotation, then the filter’s, or undefined to let the formatter fall back to Expected <filter>, got <actual>.

import { SchemaIssue } from "effect"
// Pass the default hooks explicitly when overriding only one of them.
const format = SchemaIssue.makeFormatterStandardSchemaV1({
leafHook: SchemaIssue.defaultLeafHook,
checkHook: SchemaIssue.defaultCheckHook
})
void format

Both formatter factories accept leafHook (a LeafHook, (issue: Leaf) => string) and checkHook (a CheckHook, (issue: Filter) => string | undefined). Provide your own to localize or rebrand messages.

import { SchemaIssue } from "effect"
const format = SchemaIssue.makeFormatterStandardSchemaV1({
leafHook: (issue) =>
issue._tag === "MissingKey" ? "Champ requis" : SchemaIssue.defaultLeafHook(issue)
})
const issue = new SchemaIssue.Pointer(
["email"],
new SchemaIssue.MissingKey(undefined)
)
console.log(format(issue).issues)
// => [{ message: "Champ requis", path: ["email"] }]

Walks an issue and replaces every captured actual value with a Redacted wrapper, so logging the tree never leaks sensitive input (passwords, tokens). MissingKey is returned unchanged (it has no value); other variants collapse to a redacted InvalidValue or keep structure with redacted values.

import { Option, SchemaIssue } from "effect"
const issue = new SchemaIssue.InvalidValue(Option.some("hunter2"))
const safe = SchemaIssue.redact(issue)
console.log(safe._tag) // => "InvalidValue"
// safe.actual now holds a Redacted value that prints as <redacted>

SchemaError is the error type the Effect and Exit runners place in their failure channel (decodeUnknownEffect / decodeUnknownExit and their encode counterparts). It is a plain class with _tag: "SchemaError", an issue field, and a message getter (issue.toString()). isSchemaError narrows an unknown to it.

import { Schema, Effect } from "effect"
const program = Schema.decodeUnknownEffect(Schema.Number)("oops").pipe(
Effect.catch((err) => {
// err is a SchemaError here, narrowed by the error channel type.
console.log(err._tag) // => "SchemaError"
console.log(err.message) // => "Expected number, got \"oops\""
// err.issue is the structured SchemaIssue.Issue tree
return Effect.void
})
)
void program

Use Schema.isSchemaError when narrowing an unknown (for instance, a value caught from arbitrary code) to a SchemaError.

Wraps a schema as a Standard Schema v1 object. Its ~standard.validate decodes synchronously (returning a Promise only if the schema has async parts) and reports failures via the Standard Schema formatter. Defaults to errors: "all"; accepts leafHook, checkHook, and parseOptions.

import { Schema } from "effect"
const Person = Schema.Struct({
name: Schema.NonEmptyString,
age: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 150 }))
})
const standard = Schema.toStandardSchemaV1(Person)
console.log(standard["~standard"].validate({ name: "Alice", age: 30 }))
// => { value: { name: "Alice", age: 30 } }
console.log(standard["~standard"].validate({ name: "", age: 200 }))
// => { issues: [{ path: ["name"], message: ... }, { path: ["age"], message: ... }] }

Attaches an experimental Standard JSON Schema v1 jsonSchema provider to the schema, exposing input / output JSON Schemas for draft-2020-12 or draft-07 targets. See JSON Schema for the underlying generation.

import { Schema } from "effect"
const Person = Schema.Struct({ name: Schema.String, age: Schema.Number })
const withJson = Schema.toStandardJSONSchemaV1(Person)
const input = withJson["~standard"].jsonSchema.input({ target: "draft-07" })
console.log(input.type) // => "object"

Next, see how to define your own typed errors with Schema.TaggedErrorClass, or revisit the runners that surface these issues.