Using generics in TypeScript to help with making network requests with window fetch
By default, when you use window.fetch
to make network requests, you don't get much help from TypeScript. That's because the type that is returned is any
.
You can work around this by creating your own type for the response and then casting the any
typed response to your defined type.
Why do this?
If you don't type your responses, you won't be able to use TypeScript to help you, it would instead be like using regular JavaScript. You won't get type hints in your editor when accessing a property that doesn't exist, or using that property as the wrong type.
Here is some example code we started off with:
const response = await fetch('https://httpbin.org/get')
const data = await response.json()
Typing the response
When you visit the URL https://httpbin.org/get
in the browser, you will see the response that looks something like this:
{
"args": {},
"headers": {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-CA,en-US;q=0.7,en;q=0.3",
"Dnt": "1",
"Host": "httpbin.org",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 xxxxxxxxxxxxxxx",
"X-Amzn-Trace-Id": "Root=xxxxxxxxxxxxxxxxxxxx"
},
"origin": "xx.xx.xx.xx",
"url": "https://httpbin.org/get"
}
With this information, we can make a simple type like this for the properties we care about:
type HttpBinGetResponse = {
headers: {
Accept: string
'User-Agent': string
}
url: string
}
Then we can change the existing code to this:
const response = await fetch('https://httpbin.org/get')
const data = await response.json() as HttpBinGetResponse
After that, we can see the error underline.
Making typed Get requests
Here's an example typed get request:
/**
* Make a JSON get request
* @param url Request endpoint
* @param headers Optional request headers
*/
export const makeGet = async <T>(
url: string,
headers: Record<string, string> = {}
): Promise<T> => {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...headers
}
});
if (!response.ok) {
return Promise.reject(response.statusText);
}
return (await response.json()) as T;
};
Let's go through this:
- the function
makeGet
is an asynchronous function that has a generic typeT
.T
is a naming convention for generics. I only useT
when there's one generic argument, otherwise I will name them to be more descriptive. - the function returns a promise of type
T
fetch
is called with the passed inurl
with some headers that can optionally be overridden by the caller- if the response is not ok, we reject with the status text
- otherwise we await the JSON response and cast it to
T
No deserialization
It's important to know that the casting is a tools-only thing in TypeScript. It's not the same as it is in strictly typed languages like Kotlin, Java, Swift or Rust. In those strictly typed languages, you will get a thrown exception when the deserialization occurs. After that point, you can trust it to be ok. The point of deserialization is when you get a successful response from the network request.
In TypeScript, there is no deserialization. You won't receive any errors after a successful network request. Instead, you'll get an error when you try to access properties that don't exist, or you try to use a property that does exist as a different type. So it's important to make sure that you've carefully written out your type.
Making typed Post requests
Like the above example, we can also type our post requests. In the below example, there are 2 generic types.
/**
* Make a JSON post request
* @param url Request endpoint
* @param body JSON serializable request body
* @param headers Optional request headers
*/
export const makePost = async <ReqType, ResType>(
url: string,
body: ReqType,
headers: Record<string, string> = {}
): Promise<ResType> => {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers
},
body: JSON.stringify(body)
});
if (!response.ok) {
return Promise.reject(response.statusText);
}
return (await response.json()) as ResType;
};
Here are the differences:
- an extra generic argument, so I am naming the generics
ReqType
andResType
so that it's easier during the implementation. - the
ReqType
is the type of the payload required, i.e. what you're sending to the endpoint. This is JSON stringified before being passed tofetch
.
Example Project
In the below project, I make both a get and a post in TypeScript in React. Here is the standalone code:
export type HttpBinRequest = {
foo: number;
bar: boolean;
};
export type HttpBinResponse = {
args: Record<string, string>;
data: unknown;
files: Record<string, string>;
form: Record<string, string>;
headers: Record<string, string>;
json: unknown;
origin: string;
url: string;
};
makePost<HttpBinRequest, HttpBinResponse>("https://httpbin.org/post", {
foo: 100,
bar: true
})
.then((data) => {
setOutput(
JSON.stringify(
{
// headers: data.headers,
you_sent: data.data
},
null,
2
)
);
})
.catch((e) => {
console.error("Post Error", e);
alert("Unknown error");
});
You can see the whole example project on Codesandbox.
Watch me Code
If you found this useful and want to watch me code, you can check me out on Twitch.