Error Formatters

On this page

When you're working with Effect Schema and encounter errors during decoding, or encoding functions, you can format these errors in two different ways: using the TreeFormatter or the ArrayFormatter.

TreeFormatter (default)

The TreeFormatter is the default method for formatting errors. It organizes errors in a tree structure, providing a clear hierarchy of issues.

Here's an example of how it works:

ts
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person)
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(TreeFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
{ readonly name: string; readonly age: number }
└─ ["name"]
└─ is missing
*/
ts
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person)
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(TreeFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
{ readonly name: string; readonly age: number }
└─ ["name"]
└─ is missing
*/

In this example, the tree error message is structured as follows:

  • { readonly name: string; readonly age: number } represents the schema, providing a visual representation of the expected structure. This can be customized by using annotations like identifier, title, or description.
  • ["name"] points to the problematic property, in this case, the "name" property.
  • is missing details the specific error for the "name" property.

Customizing the Output

The default error message represents the involved schemas in a TypeScript-like syntax:

ts
{ readonly name: string; readonly age: number }
ts
{ readonly name: string; readonly age: number }

You can customize this output by adding annotations such as identifier, title, or description. These annotations are applied in this order of priority and allow for a more concise and clear representation in error messages.

ts
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
}).annotations({ title: "Person" }) // Adding a title annotation
 
const result = Schema.decodeUnknownEither(Person)({})
if (Either.isLeft(result)) {
console.error(TreeFormatter.formatErrorSync(result.left))
}
/*
Person
└─ ["name"]
└─ is missing
*/
ts
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
}).annotations({ title: "Person" }) // Adding a title annotation
 
const result = Schema.decodeUnknownEither(Person)({})
if (Either.isLeft(result)) {
console.error(TreeFormatter.formatErrorSync(result.left))
}
/*
Person
└─ ["name"]
└─ is missing
*/

In this modified example, by adding a title annotation, the schema representation in the error message changes to "Person", providing a simpler and more understandable output. This helps in identifying the schema involved more quickly and improves the readability of the error messages.

Handling Multiple Errors

By default, decoding functions like decodeUnknownEither return only the first encountered error. If you require a comprehensive list of all errors, you can modify the behavior by passing the { errors: "all" } option:

ts
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person, { errors: "all" })
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(TreeFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
{ readonly name: string; readonly age: number }
├─ ["name"]
│ └─ is missing
└─ ["age"]
└─ is missing
*/
ts
import { Schema, TreeFormatter } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person, { errors: "all" })
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(TreeFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
{ readonly name: string; readonly age: number }
├─ ["name"]
│ └─ is missing
└─ ["age"]
└─ is missing
*/

This adjustment ensures that the formatter displays all errors related to the input, providing a more detailed diagnostic of what went wrong.

ParseIssueTitle Annotation

When a decoding or encoding operation fails, it's useful to have additional details in the default error message returned by TreeFormatter to understand exactly which value caused the operation to fail. To achieve this, you can set an annotation that depends on the value undergoing the operation and can return an excerpt of it, making it easier to identify the problematic value. A common scenario is when the entity being validated has an id field. The ParseIssueTitle annotation facilitates this kind of analysis during error handling.

The type of the annotation is:

ts
export type ParseIssueTitleAnnotation = (
issue: ParseIssue
) => string | undefined
ts
export type ParseIssueTitleAnnotation = (
issue: ParseIssue
) => string | undefined

If you set this annotation on a schema and the provided function returns a string, then that string is used as the title by TreeFormatter, unless a message annotation (which has the highest priority) has also been set. If the function returns undefined, then the default title used by TreeFormatter is determined with the following priorities:

  1. identifier annotation
  2. title annotation
  3. description annotation
  4. default TypeScript-like syntax

Example

ts
import type { ParseResult } from "@effect/schema"
import { Schema } from "@effect/schema"
 
const getOrderItemId = ({ actual }: ParseResult.ParseIssue) => {
if (Schema.is(Schema.Struct({ id: Schema.String }))(actual)) {
return `OrderItem with id: ${actual.id}`
}
}
 
const OrderItem = Schema.Struct({
id: Schema.String,
name: Schema.String,
price: Schema.Number
}).annotations({
identifier: "OrderItem",
parseIssueTitle: getOrderItemId
})
 
const getOrderId = ({ actual }: ParseResult.ParseIssue) => {
if (Schema.is(Schema.Struct({ id: Schema.Number }))(actual)) {
return `Order with id: ${actual.id}`
}
}
 
const Order = Schema.Struct({
id: Schema.Number,
name: Schema.String,
items: Schema.Array(OrderItem)
}).annotations({
identifier: "Order",
parseIssueTitle: getOrderId
})
 
const decode = Schema.decodeUnknownSync(Order, { errors: "all" })
 
// No id available, so the `identifier` annotation is used as the title
decode({})
/*
throws
ParseError: Order
├─ ["id"]
│ └─ is missing
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/
 
// An id is available, so the `parseIssueTitle` annotation is used as the title
decode({ id: 1 })
/*
throws
ParseError: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/
 
decode({ id: 1, items: [{ id: "22b", price: "100" }] })
/*
throws
ParseError: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ ReadonlyArray<OrderItem>
└─ [0]
└─ OrderItem with id: 22b
├─ ["name"]
│ └─ is missing
└─ ["price"]
└─ Expected a number, actual "100"
*/
ts
import type { ParseResult } from "@effect/schema"
import { Schema } from "@effect/schema"
 
const getOrderItemId = ({ actual }: ParseResult.ParseIssue) => {
if (Schema.is(Schema.Struct({ id: Schema.String }))(actual)) {
return `OrderItem with id: ${actual.id}`
}
}
 
const OrderItem = Schema.Struct({
id: Schema.String,
name: Schema.String,
price: Schema.Number
}).annotations({
identifier: "OrderItem",
parseIssueTitle: getOrderItemId
})
 
const getOrderId = ({ actual }: ParseResult.ParseIssue) => {
if (Schema.is(Schema.Struct({ id: Schema.Number }))(actual)) {
return `Order with id: ${actual.id}`
}
}
 
const Order = Schema.Struct({
id: Schema.Number,
name: Schema.String,
items: Schema.Array(OrderItem)
}).annotations({
identifier: "Order",
parseIssueTitle: getOrderId
})
 
const decode = Schema.decodeUnknownSync(Order, { errors: "all" })
 
// No id available, so the `identifier` annotation is used as the title
decode({})
/*
throws
ParseError: Order
├─ ["id"]
│ └─ is missing
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/
 
// An id is available, so the `parseIssueTitle` annotation is used as the title
decode({ id: 1 })
/*
throws
ParseError: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ is missing
*/
 
decode({ id: 1, items: [{ id: "22b", price: "100" }] })
/*
throws
ParseError: Order with id: 1
├─ ["name"]
│ └─ is missing
└─ ["items"]
└─ ReadonlyArray<OrderItem>
└─ [0]
└─ OrderItem with id: 22b
├─ ["name"]
│ └─ is missing
└─ ["price"]
└─ Expected a number, actual "100"
*/

In the examples above, we can see how the parseIssueTitle annotation helps provide meaningful error messages when decoding fails.

ArrayFormatter

The ArrayFormatter offers an alternative method for formatting errors within @effect/schema, organizing them into a more structured and easily navigable array format. This formatter is especially useful when you need a clear overview of all issues detected during the decoding or encoding processes.

The ArrayFormatter formats errors as an array of objects, where each object represents a distinct issue and includes properties such as _tag, path, and message. This structured format can help developers quickly identify and address multiple issues in data processing.

Here's an example of how it works:

ts
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person)
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(ArrayFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
[ { _tag: 'Missing', path: [ 'name' ], message: 'is missing' } ]
*/
ts
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person)
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(ArrayFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
[ { _tag: 'Missing', path: [ 'name' ], message: 'is missing' } ]
*/

Each error is formatted as an object in an array, making it clear what the error is (is missing), where it occurred (name), and its type (Missing).

Handling Multiple Errors

By default, decoding functions like decodeUnknownEither return only the first encountered error. If you require a comprehensive list of all errors, you can modify the behavior by passing the { errors: "all" } option:

ts
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person, { errors: "all" })
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(ArrayFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
[
{ _tag: 'Missing', path: [ 'name' ], message: 'is missing' },
{ _tag: 'Missing', path: [ 'age' ], message: 'is missing' }
]
*/
ts
import { ArrayFormatter, Schema } from "@effect/schema"
import { Either } from "effect"
 
const Person = Schema.Struct({
name: Schema.String,
age: Schema.Number
})
 
const decode = Schema.decodeUnknownEither(Person, { errors: "all" })
 
const result = decode({})
if (Either.isLeft(result)) {
console.error("Decoding failed:")
console.error(ArrayFormatter.formatErrorSync(result.left))
}
/*
Decoding failed:
[
{ _tag: 'Missing', path: [ 'name' ], message: 'is missing' },
{ _tag: 'Missing', path: [ 'age' ], message: 'is missing' }
]
*/

React Hook Form

If you are working with React and need form validation, @hookform/resolvers offers an adapter for @effect/schema, which can be integrated with React Hook Form for enhanced form validation processes. This integration allows you to leverage the powerful features of @effect/schema within your React applications.

For more detailed instructions and examples on how to integrate @effect/schema with React Hook Form using @hookform/resolvers, you can visit the official npm package page: React Hook Form Resolvers