| id |
schema-api-response-nested |
| title |
Decoding Nested API Responses |
| category |
validating-api-responses |
| skillLevel |
intermediate |
| tags |
schema |
api |
nested-objects |
complex-structures |
composition |
|
| lessonOrder |
8 |
| rule |
| description |
Decode Nested API Responses using Schema. |
|
| summary |
Real APIs rarely return flat objects. Responses are deeply nested: users have profiles, profiles have addresses, addresses have coordinates. |
Real APIs rarely return flat objects. Responses are deeply nested: users have profiles, profiles have addresses, addresses have coordinates.
Writing a flat schema doesn't capture this structure, and it becomes hard to validate what's actually required vs. optional. You need to compose schemas for nested structures, validate them at every level, and get clear errors when validation fails deep inside.
import { Effect, Schema } from "effect"
// Build schemas bottom-up for clarity and reuse
// Level 1: Leaf schema (no dependencies)
const Coordinate = Schema.Struct({
latitude: Schema.Number,
longitude: Schema.Number,
})
type Coordinate = typeof Coordinate.Type
// Level 2: Compose Coordinate into Address
const Address = Schema.Struct({
street: Schema.String,
city: Schema.String,
zipCode: Schema.String,
coordinates: Coordinate, // Nested schema
})
type Address = typeof Address.Type
// Level 3: Compose Address into Profile
const Profile = Schema.Struct({
bio: Schema.optional(Schema.String),
address: Address, // Nested schema
tags: Schema.Array(Schema.String),
})
type Profile = typeof Profile.Type
// Level 4: Compose Profile into User
const User = Schema.Struct({
id: Schema.Number,
name: Schema.String,
email: Schema.String,
profile: Profile, // Nested schema
})
type User = typeof User.Type
// Now create decoders at each level
const parseCoordinate = Schema.decodeUnknown(Coordinate)
const parseAddress = Schema.decodeUnknown(Address)
const parseProfile = Schema.decodeUnknown(Profile)
const parseUser = Schema.decodeUnknown(User)
// Use in pipeline
const fetchUser = (id: number) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise(() =>
fetch(`https://api.example.com/users/${id}`).then((r) => r.json())
)
// One call validates entire tree
const user = yield* parseUser(response)
// user.profile.address.coordinates is fully typed and validated
yield* Effect.log(
`${user.name} lives at ${user.profile.address.city} (${user.profile.address.coordinates.latitude})`
)
return user
})
// Alternative: Validate step-by-step for partial recovery
const fetchUserPartial = (id: number) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise(() =>
fetch(`https://api.example.com/users/${id}`).then((r) => r.json())
)
// Validate user fields first
const userFields = yield* Schema.decodeUnknown(
Schema.Struct({
id: Schema.Number,
name: Schema.String,
email: Schema.String,
})
)(response)
// Then validate nested profile
const profile = yield* Effect.orElse(
parseProfile(response.profile),
() =>
Effect.gen(function* () {
yield* Effect.log("Profile validation failed, using default")
return {
bio: undefined,
address: {
street: "Unknown",
city: "Unknown",
zipCode: "Unknown",
coordinates: { latitude: 0, longitude: 0 },
},
tags: [],
} as Profile
})
)
return { ...userFields, profile }
})
// Paginated response example
const UserResponse = Schema.Struct({
status: Schema.Literal("success", "error"),
data: Schema.Array(User),
meta: Schema.Struct({
total: Schema.Number,
page: Schema.Number,
perPage: Schema.Number,
}),
})
type UserResponse = typeof UserResponse.Type
const parseUserResponse = Schema.decodeUnknown(UserResponse)
const fetchUsers = (page: number) =>
Effect.gen(function* () {
const response = yield* Effect.tryPromise(() =>
fetch(`https://api.example.com/users?page=${page}`).then((r) =>
r.json()
)
)
const { data, meta } = yield* parseUserResponse(response)
yield* Effect.log(
`Fetched ${data.length} of ${meta.total} users (page ${meta.page})`
)
return { users: data, pagination: meta }
})
// Execute
const main = Effect.gen(function* () {
const { users, pagination } = yield* fetchUsers(1)
yield* Effect.log(`Total pages: ${Math.ceil(pagination.total / pagination.perPage)}`)
})
Effect.runPromise(main)
| Concept |
Explanation |
| Bottom-up composition |
Define leaf schemas first, compose them upward—reusable, testable at each level |
| Nested validation |
Each schema validates its level; composition means full-tree validation in one call |
| Type safety at depth |
typeof Coordinate.Type preserves types through all nesting levels |
| Clear error paths |
ParseError tells you exactly which nested field failed and why |
| Reusability |
Coordinate can be used in Address, Address in User, etc.—DRY principle |
- APIs with nested objects (users with profiles with addresses)
- Paginated responses with metadata
- Any response with 2+ levels of nesting
- When you want to validate and use intermediate structures
- APIs where you want strong type safety at all nesting levels