Using fetch with TypeScript

When migrating some code to TypeScript, I ran into a few little hurdles I want
to share with you.

In EpicReact.dev workshops, when I’m teaching how to
make HTTP requests, I use the GraphQL Pokemon API. Here’s how we make that
request:

const formatDate = date =>
  `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String(
    date.getSeconds(),
  ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`

async function fetchPokemon(name) {
  const pokemonQuery = `
    query PokemonInfo($name: String) {
      pokemon(name: $name) {
        id
        number
        name
        image
        attacks {
          special {
            name
            type
            damage
          }
        }
      }
    }
  `

  const response = await window.fetch('https://graphql-pokemon2.vercel.app/', {
    // learn more about this API here: https://graphql-pokemon2.vercel.app/
    method: 'POST',
    headers: {
      'content-type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({
      query: pokemonQuery,
      variables: {name: name.toLowerCase()},
    }),
  })

  const {data, errors} = await response.json()
  if (response.ok) {
    const pokemon = data?.pokemon
    if (pokemon) {
      // add fetchedAt helper (used in the UI to help differentiate requests)
      pokemon.fetchedAt = formatDate(new Date())
      return pokemon
    } else {
      return Promise.reject(new Error(`No pokemon with the name "${name}"`))
    }
  } else {
    // handle the graphql errors
    const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')
    return Promise.reject(error)
  }
}

Here’s an example usage/output:

fetchPokemon('pikachu').then(data => console.log(data))

this logs:

{
  "id": "UG9rZW1vbjowMjU=",
  "number": "025",
  "name": "Pikachu",
  "image": "https://img.pokemondb.net/artwork/pikachu.jpg",
  "attacks": {
    "special": [
      {
        "name": "Discharge",
        "type": "Electric",
        "damage": 35
      },
      {
        "name": "Thunder",
        "type": "Electric",
        "damage": 100
      },
      {
        "name": "Thunderbolt",
        "type": "Electric",
        "damage": 55
      }
    ]
  },
  "fetchedAt": "16:18 39.159"
}

And for the error case:

fetchPokemon('not-a-pokemon').catch(error => console.error(error))
// Logs: No pokemon with the name "not-a-pokemon"

And if we make a GraphQL error (for example, typo image as imag), then we
get:

{
  "message": "Cannot query field \"imag\" on type \"Pokemon\". Did you mean \"image\"?"
}

Alright, now that we know what fetchPokemon is supposed to do, let’s start
adding types.

Here’s how I migrate code to TypeScript:

  1. Update the filename to .ts (or .tsx if the project uses React) to enable
    TypeScript in the file
  2. Update all the code that has little red squiggles in my editor until they go
    away. Normally, I start with the inputs of the exported functions.

In this case, once we enable TypeScript on this file, we get three of these:

Parameter 'such-and-such' implicitly has an 'any' type. ts(7006)

And that’s it. One for each function. So from the start it seems like this is
going to be a cinch right? lol.

So we fix all of those:

const formatDate = (date: Date) => {
  // ...
}

async function fetchPokemon(name: string) {
  // ...
  if (response.ok) {
    // ...
  } else {
    // NOTE: Having to explicitly type the argument to `.map` means that
    // the array you're maping over isn't typed properly! We'll fix this later...
    const error = new Error(
      errors?.map((e: {message: string}) => e.message).join('\n') ?? 'unknown',
    )
    // ...
  }
}

And now the errors are all gone!

Sweet, so let’s use this thing:

async function pikachuIChooseYou() {
  const pikachu = await fetchPokemon('pikachu')
  console.log(pikachu.attacks.special.name)
}

We run that and then… uh oh… Did you catch that? We’ve got ourselves a type
error 😱 special is an array! So that should be
pikachu.attacks.special[0].name. The return value for fetchPokemon is
Promise<any>. Looks like we’re not quite done after all. So, let’s type the
expected PokemonData return value:

type PokemonData = {
  id: string
  number: string
  name: string
  image: string
  fetchedAt: string
  attacks: {
    special: Array<{
      name: string
      type: string
      damage: number
    }>
  }
}

Cool, so with that, now we can be more explicit about our return value:

async function fetchPokemon(name: string): Promise<PokemonData> {
  // ...
}

And now we’ll get a type error for that usage we had earlier and we can correct
it.

Alright, let’s get to that unfortunate explicit type for the errors.map call.
As I mentioned earlier, this is an indication that our array isn’t properly
typed.

A quick review will show that both data and errors is any:

const {data, errors} = await response.json()

This is because the return type for response.json is Promise<any>. When I
first realized this I was annoyed, but after a second of thinking about it I
realized that I don’t know what else it could be! How could TypeScript know what
data my fetch call will return? So let’s help the TypeScript compiler out with
a little type annotation:

type JSONResponse = {
  data?: {
    pokemon: Omit<PokemonData, 'fetchedAt'>
  }
  errors?: Array<{message: string}>
}
const {data, errors}: JSONResponse = await response.json()

And now we can remove the explicit type on the errors.map which is great!

const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')

Notice the use of Omit there. Because the fetchedAt property is in our
PokemonData, but it’s not coming from the API, so saying that it is would be
lying to TypeScript and future readers of the code (which we should avoid).

With that in place, we’ll now get two new errors:

// add fetchedAt helper (used in the UI to help differentiate requests)
pokemon.fetchedAt = formatDate(new Date())
return pokemon

Adding new properties to an object like this is often referred to as
“monkey-patching.”

The first is for the pokemon.fetchedAt and it says:

Property 'fetchedAt' does not exist on type 'Pick<PokemonData, "number" | "id" | "name" | "image" | "attacks">'. ts(2339)

The second is for the return pokemon and that says:

Property 'fetchedAt' is missing in type 'Pick<PokemonData, "number" | "id" | "name" | "image" | "attacks">' but required in type 'PokemonData'. ts(2741)

Well for crying out loud TypeScript, the first one is complaining that
fetchedAt shouldn’t exist, and the second one is saying that it should! Make
up your mind! 😩

We could always tell TypeScript to pipe down and use a type assertion to cast
pokemon as a full PokemonData. But I found an easier solution:

// add fetchedAt helper (used in the UI to help differentiate requests)
return Object.assign(pokemon, {fetchedAt: formatDate(new Date())})

This made both errors go away. Object.assign will combine object properties
onto the target object (the first parameter) and return that target object. This
made the compiler happy because it could detect that pokemon would go in
without fetchedAt and come out with fetchedAt.

In case you’re curious, here’s the type definition for Object.assign:

assign<T, U>(target: T, source: U): T & U;

And that’s it! We’ve now successfully typed fetch for a particular request. 🎉

One last learning here. Unfortunately, the Promise type generic only accepts
the resolved value and not the rejected value. So I can’t do:

async function fetchPokemon(name: string): Promise<PokemonData, Error> {}

Turns out this is related to another frustration of mine:

try {
  throw new Error('oh no')
} catch (error: Error) {
  //            ^^^^^ Catch clause variable type annotation
  //                  must be 'any' or 'unknown' if specified.
  //                  ts(1196)
}

The reason for this is because an error can happen for completely unexpected
reasons. TypeScript thinks you can’t possibly know what triggered the error so
therefore you can’t know what type the error will be.

This is a bit of a bummer, but it’s understandable.

Alrighty, so here’s the final version:

const formatDate = (date: Date) =>
  `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String(
    date.getSeconds(),
  ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`

type PokemonData = {
  id: string
  number: string
  name: string
  image: string
  fetchedAt: string
  attacks: {
    special: Array<{
      name: string
      type: string
      damage: number
    }>
  }
}

async function fetchPokemon(name: string): Promise<PokemonData> {
  const pokemonQuery = `
    query PokemonInfo($name: String) {
      pokemon(name: $name) {
        id
        number
        name
        image
        attacks {
          special {
            name
            type
            damage
          }
        }
      }
    }
  `

  const response = await window.fetch('https://graphql-pokemon2.vercel.app/', {
    // learn more about this API here: https://graphql-pokemon2.vercel.app/
    method: 'POST',
    headers: {
      'content-type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({
      query: pokemonQuery,
      variables: {name: name.toLowerCase()},
    }),
  })

  type JSONResponse = {
    data?: {
      pokemon: Omit<PokemonData, 'fetchedAt'>
    }
    errors?: Array<{message: string}>
  }
  const {data, errors}: JSONResponse = await response.json()
  if (response.ok) {
    const pokemon = data?.pokemon
    if (pokemon) {
      // add fetchedAt helper (used in the UI to help differentiate requests)
      return Object.assign(pokemon, {fetchedAt: formatDate(new Date())})
    } else {
      return Promise.reject(new Error(`No pokemon with the name "${name}"`))
    }
  } else {
    // handle the graphql errors
    const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')
    return Promise.reject(error)
  }
}

I hope that’s interesting and useful to you! Good luck.


Source link

مدونة تقنية تركز على نصائح التدوين ، وتحسين محركات البحث ، ووسائل التواصل الاجتماعي ، وأدوات الهاتف المحمول ، ونصائح الكمبيوتر ، وأدلة إرشادية ونصائح عامة ونصائح