| id |
schema-api-response-error-handling |
| title |
Handling Decode Failures |
| category |
validating-api-responses |
| skillLevel |
beginner |
| tags |
schema |
api |
error-handling |
validation |
recovery |
|
| lessonOrder |
22 |
| rule |
| description |
Handle Decode Failures using Schema. |
|
| summary |
You're decoding API responses with a Schema, but validation sometimes fails. You need to know *why* it failed so you can decide: retry, use a default, log it, or fail explicitly. |
You're decoding API responses with a Schema, but validation sometimes fails. You need to know why it failed so you can decide: retry, use a default, log it, or fail explicitly.
The ParseError gives you detailed info about what went wrong, but you need to handle it gracefully instead of crashing your program.
import { Effect, Schema, Exit } from "effect"
// Define the schema
const User = Schema.Struct({
id: Schema.Number,
name: Schema.String,
email: Schema.String,
})
type User = typeof User.Type
const parseUser = Schema.decodeUnknown(User)
// Strategy 1: Catch and provide default
const fetchUserWithDefault = (id: number, defaultUser: User) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise(() =>
fetch(`https://api.example.com/users/${id}`).then((r) => r.json())
)
return yield* Effect.orElse(
parseUser(response),
() =>
Effect.gen(function* () {
yield* Effect.log(`Failed to parse user ${id}, using default`)
return defaultUser
})
)
})
// Strategy 2: Catch and inspect the error
const fetchUserWithLogging = (id: number) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise(() =>
fetch(`https://api.example.com/users/${id}`).then((r) => r.json())
)
return yield* parseUser(response).pipe(
Effect.catchTag("ParseError", (error) =>
Effect.gen(function* () {
yield* Effect.log(`Validation failed: ${error.message}`)
yield* Effect.log(`Full details: ${JSON.stringify(error.issue)}`)
return yield* Effect.fail(error)
})
)
)
})
// Strategy 3: Try multiple decoders (union handling)
const UserV1 = Schema.Struct({
id: Schema.Number,
name: Schema.String,
email: Schema.String,
})
const UserV2 = Schema.Struct({
userId: Schema.Number,
fullName: Schema.String,
contactEmail: Schema.String,
})
const parseUserV1Or2 = (data: unknown) =>
Effect.gen(function* () {
// Try V1 first
const v1Result = yield* Effect.exit(Schema.decodeUnknown(UserV1)(data))
if (Exit.isSuccess(v1Result)) {
return v1Result.value
}
// Fall back to V2
const v2Result = yield* Schema.decodeUnknown(UserV2)(data)
return v2Result
})
// Strategy 4: Collect all validation errors
const fetchUsersStrict = (ids: number[]) =>
Effect.gen(function* () {
const responses = yield* Effect.tryPromise(() =>
Promise.all(
ids.map((id) =>
fetch(`https://api.example.com/users/${id}`).then((r) => r.json())
)
)
)
// This will fail on first invalid response
const users = yield* Effect.forEach(
responses,
(response) => Schema.decodeUnknown(User)(response)
)
yield* Effect.log(`Successfully decoded ${users.length} users`)
return users
})
// Handle validation errors
const main = Effect.gen(function* () {
const defaultUser: User = {
id: 0,
name: "Unknown",
email: "unknown@example.com",
}
const user = yield* fetchUserWithDefault(123, defaultUser)
yield* Effect.log(`Final user: ${user.name}`)
})
Effect.runPromise(main)
| Strategy |
When to Use |
Tradeoff |
orElse |
API may be degraded but service is important |
Risk: silently accepting bad data |
catchTag |
Need to understand failure mode |
Complexity: error inspection logic |
| Multiple decoders |
API versioning or polymorphic responses |
Overhead: multiple parsing attempts |
| Strict (no catch) |
API contract is sacred, fail fast is okay |
Risk: cascading failures if API changes |
- API responses may have optional fields
- Multiple API versions exist simultaneously
- Graceful degradation is more important than strict correctness
- You need to log/report validation failures
- You want to transform errors before returning to caller