Using fetch with TypeScript
Photo by Anthony Duran
How to make HTTP requests with fetch and TypeScript
When migrating some code to TypeScript, I ran into a few little hurdles I want to share with you.
The use case:
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:
1const formatDate = date =>2 `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String(3 date.getSeconds(),4 ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`56async function fetchPokemon(name) {7 const pokemonQuery = `8 query PokemonInfo($name: String) {9 pokemon(name: $name) {10 id11 number12 name13 image14 attacks {15 special {16 name17 type18 damage19 }20 }21 }22 }23 `2425 const response = await window.fetch('https://graphql-pokemon2.vercel.app/', {26 // learn more about this API here: https://graphql-pokemon2.vercel.app/27 method: 'POST',28 headers: {29 'content-type': 'application/json;charset=UTF-8',30 },31 body: JSON.stringify({32 query: pokemonQuery,33 variables: {name: name.toLowerCase()},34 }),35 })3637 const {data, errors} = await response.json()38 if (response.ok) {39 const pokemon = data?.pokemon40 if (pokemon) {41 // add fetchedAt helper (used in the UI to help differentiate requests)42 pokemon.fetchedAt = formatDate(new Date())43 return pokemon44 } else {45 return Promise.reject(new Error(`No pokemon with the name "${name}"`))46 }47 } else {48 // handle the graphql errors49 const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')50 return Promise.reject(error)51 }52}
Here's an example usage/output:
1fetchPokemon('pikachu').then(data => console.log(data))
this logs:
1{2 "id": "UG9rZW1vbjowMjU=",3 "number": "025",4 "name": "Pikachu",5 "image": "https://img.pokemondb.net/artwork/pikachu.jpg",6 "attacks": {7 "special": [8 {9 "name": "Discharge",10 "type": "Electric",11 "damage": 3512 },13 {14 "name": "Thunder",15 "type": "Electric",16 "damage": 10017 },18 {19 "name": "Thunderbolt",20 "type": "Electric",21 "damage": 5522 }23 ]24 },25 "fetchedAt": "16:18 39.159"26}
And for the error case:
1fetchPokemon('not-a-pokemon').catch(error => console.error(error))2// 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:
1{2 "message": "Cannot query field \"imag\" on type \"Pokemon\". Did you mean \"image\"?"3}
Typing fetch
Alright, now that we know what fetchPokemon
is supposed to do, let's start
adding types.
Here's how I migrate code to TypeScript:
- Update the filename to
.ts
(or.tsx
if the project uses React) to enable TypeScript in the file - 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:
1Parameter '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 synch right? lol.
So we fix all of those:
1const formatDate = (date: Date) => {2 // ...3}45async function fetchPokemon(name: string) {6 // ...7 if (response.ok) {8 // ...9 } else {10 // NOTE: Having to explicitly type the argument to `.map` means that11 // the array you're maping over isn't typed properly! We'll fix this later...12 const error = new Error(13 errors?.map((e: {message: string}) => e.message).join('\n') ?? 'unknown',14 )15 // ...16 }17}
And now the errors are all gone!
Using the typed fetchPokemon
Sweet, so let's use this thing:
1async function pikachuIChooseYou() {2 const pikachu = await fetchPokemon('pikachu')3 console.log(pikachu.attacks.special.name)4}
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:
1type PokemonData = {2 id: string3 number: string4 name: string5 image: string6 fetchedAt: string7 attacks: {8 special: Array<{9 name: string10 type: string11 damage: number12 }>13 }14}
Cool, so with that, now we can be more explicit about our return value:
1async function fetchPokemon(name: string): Promise<PokemonData> {2 // ...3}
And now we'll get a type error for that usage we had earlier and we can correct it.
Removing any
things
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
:
1const {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:
1type JSONResponse = {2 data?: {3 pokemon: Omit<PokemonData, 'fetchedAt'>4 }5 errors?: Array<{message: string}>6}7const {data, errors}: JSONResponse = await response.json()
And now we can remove the explicit type on the errors.map
which is great!
1const 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).
Monkey-patching with TypeScript
With that in place, we'll now get two new errors:
1// add fetchedAt helper (used in the UI to help differentiate requests)2pokemon.fetchedAt = formatDate(new Date())3return 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:
1Property '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:
1Property '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:
1// add fetchedAt helper (used in the UI to help differentiate requests)2return 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
:
1assign<T, U>(target: T, source: U): T & U;
And that's it! We've now successfully typed fetch
for a particular request. 🎉
Typing the rejected value of the promise
One last learning here. Unfortunately, the Promise
type generic only accepts
the resolved
value and not the rejected
value. So I can't do:
1async function fetchPokemon(name: string): Promise<PokemonData, Error> {}
Turns out this is related to another frustration of mine:
1try {2 throw new Error('oh no')3} catch (error: Error) {4 // ^^^^^ Catch clause variable type annotation5 // must be 'any' or 'unknown' if specified.6 // ts(1196)7}
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.
Conclusion
Alrighty, so here's the final version:
1const formatDate = (date: Date) =>2 `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')} ${String(3 date.getSeconds(),4 ).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`56type PokemonData = {7 id: string8 number: string9 name: string10 image: string11 fetchedAt: string12 attacks: {13 special: Array<{14 name: string15 type: string16 damage: number17 }>18 }19}2021async function fetchPokemon(name: string): Promise<PokemonData> {22 const pokemonQuery = `23 query PokemonInfo($name: String) {24 pokemon(name: $name) {25 id26 number27 name28 image29 attacks {30 special {31 name32 type33 damage34 }35 }36 }37 }38 `3940 const response = await window.fetch('https://graphql-pokemon2.vercel.app/', {41 // learn more about this API here: https://graphql-pokemon2.vercel.app/42 method: 'POST',43 headers: {44 'content-type': 'application/json;charset=UTF-8',45 },46 body: JSON.stringify({47 query: pokemonQuery,48 variables: {name: name.toLowerCase()},49 }),50 })5152 type JSONResponse = {53 data?: {54 pokemon: Omit<PokemonData, 'fetchedAt'>55 }56 errors?: Array<{message: string}>57 }58 const {data, errors}: JSONResponse = await response.json()59 if (response.ok) {60 const pokemon = data?.pokemon61 if (pokemon) {62 // add fetchedAt helper (used in the UI to help differentiate requests)63 return Object.assign(pokemon, {fetchedAt: formatDate(new Date())})64 } else {65 return Promise.reject(new Error(`No pokemon with the name "${name}"`))66 }67 } else {68 // handle the graphql errors69 const error = new Error(errors?.map(e => e.message).join('\n') ?? 'unknown')70 return Promise.reject(error)71 }72}
I hope that's interesting and useful to you! Good luck.