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:
- Cambio la extension del archivo a
.ts
(otsx
si el proyecto usa React) para habilitar TypeScript en el archivo. - 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.