This site runs best with JavaScript enabled.

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')}`
5
6async function fetchPokemon(name) {
7 const pokemonQuery = `
8 query PokemonInfo($name: String) {
9 pokemon(name: $name) {
10 id
11 number
12 name
13 image
14 attacks {
15 special {
16 name
17 type
18 damage
19 }
20 }
21 }
22 }
23 `
24
25 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 })
36
37 const {data, errors} = await response.json()
38 if (response.ok) {
39 const pokemon = data?.pokemon
40 if (pokemon) {
41 // add fetchedAt helper (used in the UI to help differentiate requests)
42 pokemon.fetchedAt = formatDate(new Date())
43 return pokemon
44 } else {
45 return Promise.reject(new Error(`No pokemon with the name "${name}"`))
46 }
47 } else {
48 // handle the graphql errors
49 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": 35
12 },
13 {
14 "name": "Thunder",
15 "type": "Electric",
16 "damage": 100
17 },
18 {
19 "name": "Thunderbolt",
20 "type": "Electric",
21 "damage": 55
22 }
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:

  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:

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}
4
5async function fetchPokemon(name: string) {
6 // ...
7 if (response.ok) {
8 // ...
9 } else {
10 // NOTE: Having to explicitly type the argument to `.map` means that
11 // 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: string
3 number: string
4 name: string
5 image: string
6 fetchedAt: string
7 attacks: {
8 special: Array<{
9 name: string
10 type: string
11 damage: number
12 }>
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 anythings

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 annotation
5 // 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')}`
5
6type PokemonData = {
7 id: string
8 number: string
9 name: string
10 image: string
11 fetchedAt: string
12 attacks: {
13 special: Array<{
14 name: string
15 type: string
16 damage: number
17 }>
18 }
19}
20
21async function fetchPokemon(name: string): Promise<PokemonData> {
22 const pokemonQuery = `
23 query PokemonInfo($name: String) {
24 pokemon(name: $name) {
25 id
26 number
27 name
28 image
29 attacks {
30 special {
31 name
32 type
33 damage
34 }
35 }
36 }
37 }
38 `
39
40 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 })
51
52 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?.pokemon
61 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 errors
69 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.

Discuss on TwitterEdit post on GitHub

Share article
Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Join the Newsletter



Kent C. Dodds