Skip to content

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> — failures
// are data, not exceptions. `{ errors: "all" }` collects every problem at once.
const result = Schema.decodeUnknownResult(Person, { errors: "all" })({
name: 42
})
if (Result.isFailure(result)) {
// Every Issue has a toString(), so String(issue) is a ready-made message.
console.error(String(result.failure))
}

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

  • decodeUnknownResult / decodeUnknownOption / decodeUnknownExit — the failure channel carries the SchemaIssue.Issue directly.
  • decodeUnknownEffect — the Effect fails with a SchemaIssue.Issue in its error channel.
  • decodeUnknownSync — throws a plain Error whose message is the formatted string and whose cause holds the SchemaIssue.Issue.
import { Schema } 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"
}
}

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 SchemaIssue.Issue; format it for logging.
Effect.catch((issue) => Effect.logError(String(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 { Schema, 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)) {
// { issues: [{ message, path }, ...] } — one entry per failing field.
console.error(formatter(result.failure).issues)
}

Next, see how to define your own typed errors with Schema.TaggedErrorClass.