Fetch con TypeScript

Enero 25 de 2021

This is a translation of the original post using fetch with TypeScript by Kent C. Dodds.

Cuando estaba migrando código a TypeScript, me encontré con algunos pequeños obstáculos que quisiera compartir contigo.

El caso de uso:

En los talleres de EpicReact.dev, cuando enseño cómo hacer una solicitud HTTP, utilizo la API GraphQL Pokemon. Así es como se hace la solicitud:

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)
  }
}

Y aquí está un ejemplo de uso/salida:

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

Esto registra:

{
  "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"
}

Y para el caso de error:

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

Y si hacemos un error de GraphQL (por ejemplo, ‘image’ como ‘imag’) entonces nos da:

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

Tipificando Fetch

Bueno, ahora que sabemos lo que fetchPokemon debe hacer, empecemos a poner tipos. Así es como transporto código a TypeScript:

  1. Cambio la extension del archivo a .ts (o tsx si el proyecto usa React) para habilitar TypeScript en el archivo.
  2. Mejoro todo el código que tiene pequeños garabatos rojos en mi editor hasta que desaparecen. Usualmente comienzo con los inputs de las funciones exportadas.

En este caso, tan pronto activamos TypeScript en este archivo, recibimos tres de estos:

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

Y nada más. Uno por cada función. Así que desde el comienzo, parece que esto va a ser pan comido, ¿no?, lol.

Arreglamos todos éstos:

ts [1,5,13]
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',
    )
    // ...
  }
}

¡Y los errores se han ido!

Utilizando ‘fetchPokemon’ tipificado

Genial, ahora utilicémoslo:

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

Ejecutamos este código y … oh, oh. ¿Te diste cuenta? Nos hemos dado un error de tipo special ¡en un arreglo! Así que esto debería ser pikachu.attacks.special[0].name. El valor que fetchPokemon devuelve es Promise<any>. Parece que después de todo, no hemos terminado. Tipifiquemos el valor esperado de PokemonData:

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

Genial, con esto, ahora podemos ser mas explícitos sobre nuestro valor devuelto:

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

Y ahora recibiremos un error de tipo por el uso anterior y podemos corregirlo.

Quitando los any

Muy bien, pasemos ahora al desafortunado caso del tipo explícito en el llamado a errors.map. Como lo mencioné antes, esto nos indica que nuestro arreglo no fue tipificado apropiadamente.

Una revisión rápida nos mostrará que tanto data y errores son any:

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

Esto se debe a que el tipo devuelto por response.json es Promise<any>. Al principio, cuando descubrí ésto, estaba un poco molesto, pero después de un segundo de pensarlo, ¡me dí cuenta de que no sé como mas podría ser! ¿Cómo puede TypeScript saber qué datos serán devueltos por mi llamado a fetch? Así que ayudemos al compilador de TypeScript un poco con una pequeña anotación de tipo:

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

Y ahora podemos quitar el tipo específico en errors.map, ¡que es genial!

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

Nota el uso de Omit allí. Porque la propiedad fetchedAt esta en nuestro PokemonData, pero no viene de la API, por tanto decir que viene de allí sería mentirle a TypeScript y a futuros lectores del código (lo cual debemos evitar).

Monkey-patching con TypeScript

Con esto en su lugar, ahora recibimos dos nuevos errores:

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

Anadir nuevas propiedades a un objeto de esta forma se conoce comunmente como “monkey-patching”.

El primero es para pokemon.fetchedAt y dice:

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

El segundo es para return pokemon y dice:

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

Bueno TypeScript, por favor, el primer error se queja de que fetchedAt no debería existir, y el segundo, ¡dice que sí debería! Haber, ¡aclárate! 😩

Siempre es posible decirle a TypeScript que se calme y use un type assertion para resolver pokemon como un completo PokemonData. Pero encontré una solución más sencilla:

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

Esto hizo que ambos errores se fueran. Object.assign combinara las propiedades del objeto en un objeto-blanco [target object] (el primer parámetro) y devolverá ese objeto-blanco. Esto alegró al compilador, porque pudo detectar que pokemon entrará sin fetchedAt y saldrá con fetchedAt.

En caso de que aún tengas curiosidad, aquí esta la definición de tipo para Object.assign:

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

¡Y eso es todo! Hemos tipificado exitosamente fetch para una solicitud particular.

Tipificando el valor rechazado de la promesa

Una última enseñanza. Desafortunadamente, el tipo genérico Promise sólo acepta el valor resolved y no el valor rejected. Por tanto no puedo:

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

Resulta que ésto está relacionado con otra frustración mía:

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

La razón de ello es que un error puede pasar por razones completamente inesperadas. TypeScript entiende que no puedes saber qué causó el error, por tanto, no puedes saber qué tipo de error será.

Esto es un poco fastidioso, pero es entendible.

Conclusión

Muy bien, acá esta la versión final:

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)
  }
}

¡Espero que ésto haya sido interesante y útil! Buena suerte.