Skip to content

Latest commit

 

History

History
196 lines (158 loc) · 5.8 KB

File metadata and controls

196 lines (158 loc) · 5.8 KB
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.

Problem

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.

Solution

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)

Why This Works

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

When to Use

  • 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

Related Patterns