← Back to blog

Building a Pokédex on iOS — Part 3: DTOs and the API contract

Part 3 of the PokeTracker series: what DTOs are, why we don't use API data directly, and how we define the contract with PokéAPI.

In the previous post we built the HTTP client. Now we need the types it’s going to decode. Before writing code, it’s worth answering a question that comes up often in iOS projects: why do we need DTOs if we can already make Decodable structs and use them directly as domain models?


Why we don’t use the JSON directly

Imagine PokéAPI returns this for a Pokémon in the list:

{
  "name": "bulbasaur",
  "url": "https://pokeapi.co/api/v2/pokemon/1/"
}

The response doesn’t include the ID or the sprite URL. The ID is embedded in the resource URL. The sprite lives on a different server (raw.githubusercontent.com). If you make a Decodable struct with id and imageURL, the decoder will fail because those fields don’t exist in the JSON.

The obvious solution is to make a struct that actually reflects what comes back: name and url. And from there, derive the ID and the sprite URL. That’s a DTO: a type whose only responsibility is to map what the API returns.

If PokéAPI later changes its format, or you switch sprite providers, you only touch the DTO. The rest of the code — ViewModels, views, business logic — doesn’t care.


URL+Extensions

Before the DTOs we need an extension. Create Core/Extensions/URL+Extensions.swift:

import Foundation

extension URL {
    var pokemonID: Int? {
        let components = pathComponents
        guard let last = components.last, let id = Int(last) else { return nil }
        return id
    }
}

PokéAPI always includes a URL like https://pokeapi.co/api/v2/pokemon/1/. The ID is the last component of the path. This extension extracts it.


PokemonListDTO

Create Features/Pokemon/Data/DTOs/PokemonListDTO.swift:

import Foundation

struct PokemonListDTO: Decodable, Sendable {
    let count: Int
    let next: String?
    let previous: String?
    let results: [PokemonListItemDTO]
}

struct PokemonListItemDTO: Decodable, Sendable {
    let name: String
    let url: String

    var id: Int? {
        URL(string: url)?.pokemonID
    }

    var spriteURL: URL? {
        guard let id = id else { return nil }
        return URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png")
    }
}

PokemonListDTO maps exactly what the /pokemon endpoint returns. The next field is optional because it doesn’t exist on the last page.

PokemonListItemDTO has only two fields from the JSON — name and url. The id and spriteURL properties are derived from those fields: they’re computed, not stored, and don’t affect the Decodable conformance.

This separation means the DTO has exactly the shape of the JSON, nothing more. No artificial optional properties, no business logic stuffed into the DTO.


PokemonDetailDTO

The detail endpoint response is much richer. Create Features/Pokemon/Data/DTOs/PokemonDetailDTO.swift:

import Foundation

struct PokemonDetailDTO: Decodable, Sendable {
    let id: Int
    let name: String
    let baseExperience: Int?
    let height: Int
    let weight: Int
    let sprites: SpritesDTO
    let stats: [StatEntryDTO]
    let types: [TypeEntryDTO]
    let abilities: [AbilityEntryDTO]
    let moves: [MoveEntryDTO]

    struct SpritesDTO: Decodable, Sendable {
        let frontDefault: String?
        let other: OtherSpritesDTO?

        struct OtherSpritesDTO: Decodable, Sendable {
            let officialArtwork: OfficialArtworkDTO?

            enum CodingKeys: String, CodingKey {
                case officialArtwork = "official-artwork"
            }

            struct OfficialArtworkDTO: Decodable, Sendable {
                let frontDefault: String?
            }
        }
    }

    struct StatEntryDTO: Decodable, Sendable {
        let baseStat: Int
        let stat: NamedResourceDTO
    }

    struct TypeEntryDTO: Decodable, Sendable {
        let slot: Int
        let type: NamedResourceDTO
    }

    struct AbilityEntryDTO: Decodable, Sendable {
        let ability: NamedResourceDTO
        let isHidden: Bool
    }

    struct MoveEntryDTO: Decodable, Sendable {
        let move: NamedResourceDTO
    }
}

struct NamedResourceDTO: Decodable, Sendable {
    let name: String
    let url: String
}

NamedResourceDTO appears in several places across the API: types, abilities, moves. PokéAPI always uses the same { name, url } pattern to reference resources. A single reusable struct covers all those cases.

The OfficialArtworkDTO case illustrates why we sometimes do need manual CodingKeys: the key in the JSON is "official-artwork" with a hyphen, which isn’t a valid Swift identifier. .convertFromSnakeCase won’t handle that automatically.


PokemonSpeciesDTO and EvolutionChainDTO

Create Features/Pokemon/Data/DTOs/PokemonSpeciesDTO.swift:

import Foundation

struct PokemonSpeciesDTO: Decodable, Sendable {
    let id: Int
    let name: String
    let flavorTextEntries: [FlavorTextEntryDTO]
    let genera: [GenusEntryDTO]
    let evolutionChain: EvolutionChainReferenceDTO

    var englishFlavorText: String? {
        flavorTextEntries
            .first { $0.language.name == "en" }?
            .flavorText
            .replacingOccurrences(of: "\n", with: " ")
            .replacingOccurrences(of: "\u{000C}", with: " ")
    }

    var englishGenus: String? {
        genera.first { $0.language.name == "en" }?.genus
    }

    struct FlavorTextEntryDTO: Decodable, Sendable {
        let flavorText: String
        let language: NamedResourceDTO
    }

    struct GenusEntryDTO: Decodable, Sendable {
        let genus: String
        let language: NamedResourceDTO
    }

    struct EvolutionChainReferenceDTO: Decodable, Sendable {
        let url: String

        var id: Int? {
            URL(string: url)?.pokemonID
        }
    }
}

The computed properties englishFlavorText and englishGenus are filters over the DTO’s arrays. PokéAPI returns descriptions in all available languages — we pick English right here. We also clean up the form-feed characters (\u{000C}) that appear in some of the game’s text entries.

Also create Features/Pokemon/Data/DTOs/EvolutionChainDTO.swift:

import Foundation

struct EvolutionChainDTO: Decodable, Sendable {
    let id: Int
    let chain: ChainLinkDTO

    struct ChainLinkDTO: Decodable, Sendable {
        let species: NamedResourceDTO
        let evolvesTo: [ChainLinkDTO]
    }
}

The evolution chain is recursive in the API: each node has an evolvesTo that can contain more nodes. The ChainLinkDTO struct references itself, which is perfectly valid in Swift.


What’s next

With the DTOs in place, in Post 4 we build the Repository: the component that uses the HTTP client to make the calls, receives the DTOs, and exposes the data to the rest of the app.