Understanding Decode Errors

Avatar of Hemanta SundarayHemanta Sundaray

In the previous chapter, we looked at six decoding APIs and saw what happens when decoding fails. But I didn’t explain the error outputs in detail. I will do so in this chapter, so that before we move on to the upcoming chapters, you have a clear mental model of how Effect Schema structures its errors.

There are two error types that decoding APIs produce: a plain JavaScript Error and a SchemaError. Which one you get depends on which API you use.

APIError Type
decodeUnknownSyncError
decodeUnknownExitSchemaError
decodeUnknownOptionNone
decodeUnknownEffectSchemaError
decodeUnknownPromiseIssue
decodeSyncError

The reason for the split is that SchemaError is an Effect-native error type. It’s designed to work with Effect’s typed error channel, which is why only the Effect-aware APIs (decodeUnknownExit and decodeUnknownEffect) produce it. The rest operate outside of Effect’s error system, so they use plain JavaScript Error or raw issue objects instead.

Let’s look at each one.

Plain Error

decodeUnknownSync and decodeSync throw a plain JavaScript Error when decoding fails. The human-readable message lives on error.message, and the structured issue data is attached to error.cause.

schema.ts
import { Schema, SchemaIssue } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
age: Schema.Number,
});
const decode = Schema.decodeUnknownSync(UserSchema);
try {
decode({ name: 42, age: 30 });
} catch (error) {
if (error instanceof Error) {
console.log("Message:", error.message);
if (SchemaIssue.isIssue(error.cause)) {
console.log("Issue:", error.cause);
}
}
}

Output:

Terminal
Message: Expected string, got 42
at ["name"]
Issue: Composite {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Composite',
ast: Objects {
'~effect/Schema': '~effect/Schema',
annotations: undefined,
checks: undefined,
encoding: undefined,
context: undefined,
_tag: 'Objects',
propertySignatures: [ [PropertySignature], [PropertySignature] ],
indexSignatures: []
},
actual: { _id: 'Option', _tag: 'Some', value: { name: 42, age: 30 } },
issues: [
Pointer {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Pointer',
path: [Array],
issue: [InvalidType]
}
]
}

error.message gives you a formatted string you can log or display. error.cause holds the raw issue tree, which is the structured representation of what went wrong. We’ll look at the issue tree in detail shortly.

Notice the SchemaIssue.isIssue check. SchemaIssue is a module in the Effect library that contains all the types, constructors, and utilities related to schema issues. You’ll see it throughout this course whenever we work with error data. Here, we use SchemaIssue.isIssue to confirm that error.cause is actually a schema issue before working with it.

SchemaError

decodeUnknownExit and decodeUnknownEffect produce a SchemaError instead of a plain Error. A SchemaError has two properties:

  • message: A human-readable string describing the error.
  • issue: The raw issue tree.

Let’s see this with decodeUnknownExit:

schema.ts
import { Schema } from "effect";
const UserSchema = Schema.Struct({
name: Schema.String,
age: Schema.Number,
});
const result = Schema.decodeUnknownExit(UserSchema)({ name: 42, age: 30 });
console.log(String(result));

Output:

Terminal
Failure(Cause([Fail(SchemaError(Expected string, got 42
at ["name"]))]))

You’ve seen this output format in the previous chapter. Now let’s break down what each layer means:

  • Failure: Decoding did not succeed. The Exit is a Failure variant, not a Success.
  • Cause: A container that tracks why the operation failed. Cause is an Effect data structure for representing error chains.
  • [...]: The array of Effect-level failure reasons (Fail, Die, Interrupt). For a single schema decode call, this is usually one Fail(SchemaError).
  • Fail: A specific type of reason representing a “known” or “expected” failure (as opposed to an unexpected defect).
  • SchemaError: The actual error object produced by the Schema module. It contains the formatted message: Expected string, got 42 at ["name"].

To access the SchemaError programmatically, you iterate over result.cause.reasons. If a reason is a Fail variant, its .error property holds the SchemaError:

schema.ts
import { Schema } from "effect";
const User = Schema.Struct({
name: Schema.String,
age: Schema.Number,
});
const result = Schema.decodeUnknownExit(User)({ name: 42, age: 30 });
if (result._tag === "Failure") {
const fail = result.cause.reasons[0];
if (fail._tag === "Fail") {
const schemaError = fail.error;
console.log("Message:", schemaError.message);
console.log("Issue:", schemaError.issue);
}
}

Output:

Terminal
Message: Expected string, got 42
at ["name"]
Issue: Composite {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Composite',
ast: Objects {
'~effect/Schema': '~effect/Schema',
annotations: undefined,
checks: undefined,
encoding: undefined,
context: undefined,
_tag: 'Objects',
propertySignatures: [ [PropertySignature], [PropertySignature] ],
indexSignatures: []
},
actual: { _id: 'Option', _tag: 'Some', value: { name: 42, age: 30 } },
issues: [
Pointer {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Pointer',
path: [Array],
issue: [InvalidType]
}
]
}

schemaError.message gives you the same human-readable string you saw in the String() output. schemaError.issue gives you the raw issue tree. Let’s look at what that tree contains.

The Issue Tree

The issue property is the source of truth for the error. It’s a tree structure that mirrors the shape of your schema, so you can always trace an error back to the exact part of the input that caused it.

Let’s log the issue tree:

schema.ts
import { Schema } from "effect";
const User = Schema.Struct({
name: Schema.String,
age: Schema.Number,
});
const result = Schema.decodeUnknownExit(User)(
{ name: 42, age: "old" },
{ errors: "all" },
);
if (result._tag === "Failure") {
const fail = result.cause.reasons[0];
if (fail._tag === "Fail") {
console.log(fail.error.issue);
}
}

Output:

Terminal
Composite {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Composite',
ast: Objects {
'~effect/Schema': '~effect/Schema',
annotations: undefined,
checks: undefined,
encoding: undefined,
context: undefined,
_tag: 'Objects',
propertySignatures: [ [PropertySignature], [PropertySignature] ],
indexSignatures: []
},
actual: { _id: 'Option', _tag: 'Some', value: { name: 42, age: 'old' } },
issues: [
Pointer {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Pointer',
path: [Array],
issue: [InvalidType]
},
Pointer {
'~effect/SchemaIssue/Issue': '~effect/SchemaIssue/Issue',
_tag: 'Pointer',
path: [Array],
issue: [InvalidType]
}
]
}

There’s a lot here, but the structure is straightforward once you know what to look for. The tree is made up of three categories of nodes. Let’s walk through them top-down, following the output.

Composite is the root node. It groups multiple child issues together. You can see it has an issues array containing two entries. This is because our input has two fields that failed (name and age), and we used { errors: "all" } to collect all of them. The actual field shows the input that was received: { name: 42, age: 'old' }.

Pointer nodes add location information. Each Pointer wraps a child issue and attaches a path to it, telling you which property the error occurred at. In the output above, there are two Pointer nodes inside the issues array, one for each failing field. When you see a path like ["name"] in an error message, it was built by reading the path from these Pointer nodes.

InvalidType is a leaf node. Leaf nodes are the terminal nodes at the bottom of the tree. They describe what actually went wrong. In this case, both fields have an InvalidType issue, meaning the values had the wrong JavaScript type (expected a string, got a number, and expected a number, got a string).

InvalidType is the most common leaf issue type. Effect Schema has six leaf issue types in total:

  • InvalidType: The value has the wrong JavaScript type.
  • InvalidValue: The value has the right type but fails a validation rule.
  • MissingKey: A required key is absent from the input object.
  • UnexpectedKey: An extra key is present in the input object when decoding is configured with { onExcessProperty: "error" }.
  • Forbidden: The operation is not allowed (e.g., encoding a value that is decode-only).
  • OneOf: A union in oneOf mode matched more than one branch (an ambiguous match).

So putting it all together: a Composite holds multiple issues, each Pointer tells you where the error is, and the leaf node tells you what went wrong. Understanding this structure is important because every error handling feature in Effect Schema ultimately reads from this tree.

Sign in to save progress

Stay in the loop

Get notified when new chapters are added and when this course is complete.