Skip to content

Fetch Wrapper in TypeScript

AE

Alexander Eckert

July 12, 2020

The Fetch API is a native browser function that provides an interface for fetching resources asynchronous across the network. How can we use fetch to simplify the code for making HTTP requests? How can we take full advantage of TypeScript's type system? Let's find out …

Lazlo in Snow

Photo by Kameron Kincade

How to use fetch

It is simple to get started with fetch:

const response = await fetch('https://jsonplaceholder.typicode.com/todos')
const response = await fetch('https://jsonplaceholder.typicode.com/todos')

All we had to do to fetch data from a REST API is provide the URL. We are using the fantastic JSONPlaceholder fake REST API for our example code. The endpoint /todos will respond with a list of to-dos. All we need to do to get the response body is to call the json method as follows:

const body = await response.json()
const body = await response.json()

Note, that we need to use the await keyword again because it is asynchronous. We need to await the response before calling json.

Creating a fetch wrapper with typed response data

Let's create a wrapper for fetch combining these two lines of code as well as adding types:

fetch.ts
async function http<T>(request: RequestInfo): Promise<T> {
	const response = await fetch(request)
	return await response.json()
}
 
// example consuming code
type Todo = {
	userId: number
	id: string
	title: string
	completed: boolean
}
 
const data = await http<Todo[]>('https://jsonplaceholder.typicode.com/todos')
fetch.ts
async function http<T>(request: RequestInfo): Promise<T> {
	const response = await fetch(request)
	return await response.json()
}
 
// example consuming code
type Todo = {
	userId: number
	id: string
	title: string
	completed: boolean
}
 
const data = await http<Todo[]>('https://jsonplaceholder.typicode.com/todos')

Our fetch wrapper function takes in a generic parameter T for the type of the response body, hence data is strongly typed as Todo[] in our consuming code.

Catching errors for HTTP error codes

So far our fetch wrapper does not handle errors very graciously, so let's improve that.

fetch.ts
async function http<T>(request: RequestInfo): Promise<T> {
	const response = await fetch(request)
 
	if (!response.ok) {
		throw new Error({ name: response.status, message: response.statusText })
	}
 
	// may error if there is no body, return empty array
	return response.json().catch(() => ({}))
}
 
// example consuming code
type Todo = {
	userId: number
	id: string
	title: string
	completed: boolean
}
 
try {
	const data = await http<Todo[]>('https://jsonplaceholder.typicode.com/todos')
} catch (error) {
	console.log('Error', error)
}
fetch.ts
async function http<T>(request: RequestInfo): Promise<T> {
	const response = await fetch(request)
 
	if (!response.ok) {
		throw new Error({ name: response.status, message: response.statusText })
	}
 
	// may error if there is no body, return empty array
	return response.json().catch(() => ({}))
}
 
// example consuming code
type Todo = {
	userId: number
	id: string
	title: string
	completed: boolean
}
 
try {
	const data = await http<Todo[]>('https://jsonplaceholder.typicode.com/todos')
} catch (error) {
	console.log('Error', error)
}

HTTP methods

We can also use other HTTP methods than GET with our fetch wrapper.

type Post = {
	id: string
	userId: number
	title: string
	body: string
}
 
const request = new Request('https://jsonplaceholder.typicode.com/posts', {
	method: 'post',
	body: JSON.stringify({
		title: 'my post',
		body: 'some content',
		userId: 1,
	}),
	headers: {
		'Content-type': 'application/json; charset=UTF-8',
	},
})
 
const response = await http<Post>(request)
type Post = {
	id: string
	userId: number
	title: string
	body: string
}
 
const request = new Request('https://jsonplaceholder.typicode.com/posts', {
	method: 'post',
	body: JSON.stringify({
		title: 'my post',
		body: 'some content',
		userId: 1,
	}),
	headers: {
		'Content-type': 'application/json; charset=UTF-8',
	},
})
 
const response = await http<Post>(request)

This will create a new todo and return to use the created post. Note, that the body needs to be stringified by using JSON.stringify(). However, I don't feel like typing so much for every post request. So let's refactor our http wrapper to expose a helper function for each HTTP method.

fetch.ts
async function http<T>(path: string, config: RequestInit): Promise<T> {
	const request = new Request(path, config)
	const response = await fetch(request)
 
	if (!response.ok) {
		throw new Error({ name: response.status, message: response.statusText })
	}
 
	// may error if there is no body, return empty array
	return response.json().catch(() => ({}))
}
 
export async function get<T>(path: string, config?: RequestInit): Promise<T> {
	const init = { method: 'get', ...config }
	return await http<T>(path, init)
}
 
export async function post<T, U>(
	path: string,
	body: T,
	config?: RequestInit
): Promise<U> {
	const init = { method: 'post', body: JSON.stringify(body), ...config }
	return await http<U>(path, init)
}
 
export async function put<T, U>(
	path: string,
	body: T,
	config?: RequestInit
): Promise<U> {
	const init = { method: 'put', body: JSON.stringify(body), ...config }
	return await http<U>(path, init)
}
fetch.ts
async function http<T>(path: string, config: RequestInit): Promise<T> {
	const request = new Request(path, config)
	const response = await fetch(request)
 
	if (!response.ok) {
		throw new Error({ name: response.status, message: response.statusText })
	}
 
	// may error if there is no body, return empty array
	return response.json().catch(() => ({}))
}
 
export async function get<T>(path: string, config?: RequestInit): Promise<T> {
	const init = { method: 'get', ...config }
	return await http<T>(path, init)
}
 
export async function post<T, U>(
	path: string,
	body: T,
	config?: RequestInit
): Promise<U> {
	const init = { method: 'post', body: JSON.stringify(body), ...config }
	return await http<U>(path, init)
}
 
export async function put<T, U>(
	path: string,
	body: T,
	config?: RequestInit
): Promise<U> {
	const init = { method: 'put', body: JSON.stringify(body), ...config }
	return await http<U>(path, init)
}

Note, that the get method only has one generic argument for the response body. However, post and put each have two generic arguments. The first is for the request body and the second one for the response body. Let's see an example to clarify this.

import * as fetch from './fetch.ts'
 
type RequestBody = {
	userId: number
	title: string
	body: string
}
 
type ResponseBody = RequestBody & {
	id: string
}
 
const newPost = {
	userId: 1,
	title: 'my post',
	body: 'some content',
}
 
const response = await fetch.post<RequestBody, ResponseBody>(
	'https://jsonplaceholder.typicode.com/posts',
	newPost
)
import * as fetch from './fetch.ts'
 
type RequestBody = {
	userId: number
	title: string
	body: string
}
 
type ResponseBody = RequestBody & {
	id: string
}
 
const newPost = {
	userId: 1,
	title: 'my post',
	body: 'some content',
}
 
const response = await fetch.post<RequestBody, ResponseBody>(
	'https://jsonplaceholder.typicode.com/posts',
	newPost
)

This is much better! We are still calling our basic fetch wrapper, but we set the correct HTTP method and serialize the request body. This is much simpler to use! TypeScript helps us to strictly type the response as well as the request body.