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))}Where the issue lives
Section titled “Where the issue lives”How you reach the issue depends on the runner you chose in basic usage:
decodeUnknownResult— the failure channel carries theSchemaIssue.Issuetree directly (no wrapper);result.failureis the issue.decodeUnknownEffect— the Effect fails with aSchema.SchemaErrorin its error channel; read.issuefor the tree.decodeUnknownExit— theExitfails with aSchema.SchemaError(Exit.Exit<A, Schema.SchemaError>); read.issuefor the tree.decodeUnknownSync— throws a plainErrorwhosemessageis the formatted string (issue.toString()) and whosecauseholds theSchemaIssue.Issuetree.decodeUnknownOption— collapses the failure toOption.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}errors: “first” vs “all”
Section titled “errors: “first” vs “all””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 errorsThe default string formatter
Section titled “The default string formatter”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"]Inspecting the issue tree
Section titled “Inspecting the issue tree”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 // illustrativeThe 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.
Standard Schema output
Section titled “Standard Schema output”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.
The Issue ADT
Section titled “The Issue ADT”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.
| Tag | Kind | Produced when |
|---|---|---|
InvalidType | leaf | runtime type mismatch (e.g. string expected, got null) |
InvalidValue | leaf | right type, wrong value (failed value constraint) |
MissingKey | leaf | a required struct key / tuple index is absent |
UnexpectedKey | leaf | an extra key is present under strict validation |
Forbidden | leaf | a forbidden operation (e.g. async work in a sync runner) |
OneOf | leaf | a oneOf union matched more than one member |
Filter | composite | a refinement check (Schema.makeFilter / .check) rejected the value |
Encoding | composite | a transformation (decodeTo / encodeTo) failed |
Pointer | composite | adds a property-key path to an inner issue |
Composite | composite | aggregates sibling issues under one schema node |
AnyOf | composite | a value matched no member of a union |
InvalidType
Section titled “InvalidType”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"}InvalidValue
Section titled “InvalidValue”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"MissingKey
Section titled “MissingKey”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"UnexpectedKey
Section titled “UnexpectedKey”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 isExtraForbidden
Section titled “Forbidden”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 isAmbiguousFilter
Section titled “Filter”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 describeFilterSee filters for the checks that produce Filter issues.
Encoding
Section titled “Encoding”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}Pointer
Section titled “Pointer”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\"]"Composite
Section titled “Composite”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"}Issue helpers
Section titled “Issue helpers”isIssue
Section titled “isIssue”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)) // => trueconsole.log(SchemaIssue.isIssue("nope")) // => falsegetActual
Section titled “getActual”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: "" }Formatters
Section titled “Formatters”A SchemaIssue.Formatter<Format> is a function Issue => Format. The module
ships two factories plus the customizable hooks they build on.
makeFormatterDefault
Section titled “makeFormatterDefault”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"}makeFormatterStandardSchemaV1
Section titled “makeFormatterStandardSchemaV1”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"] }] }defaultFormatter
Section titled “defaultFormatter”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"defaultLeafHook
Section titled “defaultLeafHook”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 \"\""defaultCheckHook
Section titled “defaultCheckHook”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 formatCustomizing messages with hooks
Section titled “Customizing messages with hooks”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"] }]redact
Section titled “redact”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>Schema-level entry points
Section titled “Schema-level entry points”Schema.SchemaError / Schema.isSchemaError
Section titled “Schema.SchemaError / Schema.isSchemaError”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 programUse Schema.isSchemaError when narrowing an unknown (for instance, a value
caught from arbitrary code) to a SchemaError.
Schema.toStandardSchemaV1
Section titled “Schema.toStandardSchemaV1”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: ... }] }Schema.toStandardJSONSchemaV1
Section titled “Schema.toStandardJSONSchemaV1”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.