Advanced Typescript by Example: API Service Manager
Implementing a generic API service manager with Typescript in React

This article will take you through the implementation of an API service manager written in Typescript to efficiently handle web services, consisting of two classes along with their associated types. These classes will be embedded within a Create React App project and tested at runtime within App.tsx
.
To gain clarity on the concepts talked about, the full project is available to browse through here on Github.
What is an API service manager?
The purpose of an API manager is to manage a web service, from its authentication to making requests and handling responses — that will either result in a successful or failed call. The manager must ensure type safety along the way, fully typing request and response objects. To prevent re-inventing the wheel, requests will be sent via Javascript’s native fetc
h() API, that will work hand-in-hand with the manager.
In order to ensure objects can be typed at runtime, some classes and types will be generic. We will refrain from using the any
type throughout; any
is not helpful, especially when dealing with arbitrary data from remote services.
For a refresher on how generic types are used in Typescript, I published an article on the subject explaining what they are and how they are used:
Typescript Generics Explained
Learn what generics are and how they are used in Typescript
medium.com
As well as being generic, we also want classes to provide a fluent interface. This can be achieved by chain multiple function calls together like so:
const api =
new APIService()
.headers(...)
.method('POST')
.somethingElse();
^
chaining methods in 1 expression
You most likely have used chained methods classes in other modules. They are useful for configuring objects like database queries, and in this case especially useful for configuring an API service, all in one expression. This is achieved by simply returning the class instance from each chained method; we’ll visit how to correctly type these return values too.
Getters and setters will also be used, of which Typescript provide specific syntax for. Fluent interfaces is nice, but we do not want to explicitly rely on it to set values — that’s where setters come into play. We’ll also discover that Typescript provides minimal syntax for assigning constructor arguments to class instances; this is dubbed automatic constructor parameter assignment. More on that a bit further down.
Along with all the above, we’ll visit ways to optimise types, how to type associative arrays, and more. Let’s get to it.
Setting up the project
If you wish to set up the project from scratch, generate a new Create React App typescript project with the following commands:
# generate new CRA typescript project
yarn create react-app api-service --typescript
# jump into the folder and install dependencies
cd api-service
yarn
# start server
yarn start
Straight away the project will be running at http://localhost:3000
, with the rotating React logo greeting you upon visiting.
Alternatively, clone the completed project from Github:
# clone the project
git clone https://github.com/rossbulat/ts-api-service-manager.git
# jump into the folder and install dependencies
cd ts-api-service-manager
yarn
# start server
yarn start
Within the src/
directory of the project, only three new files have been introduced that make up the API manager:
types.ts
All the types associated with the project will be defined hereAPIService.ts:
This file will house two classes:APIService
andRequestBody
.- A
constants.ts
file is also included, that simply defines some values to limit hard coded string values floating aroundApp.tsx
. To keep this talk focused on Typescript it will not be mentioned further, but is available to browse on Github.
APIService Class
The aim of APIService
is to provide a means to configure an API request and format a request object for fetch()
to use. It therefore needs to store configuration data such as:
- An authentication token, or other means of authentication. We will keep things simple here by using an
authToken
string - The headers to be sent with the request, that
fetch()
expects to be in the form of an associative array - The request method, that we will limit to
GET
andPOST
in this talk - Any other configuration that a remote service requires. The modular structure of
APIService
means you can simply add more methods to it without breaking any other.
Automatic constructor parameter assignment
Let’s define the structure of APIService
and accept an authToken
within the constructor, and utilise Typescript’s automatic constructor parameter assignment feature:
// APIService.ts
export class APIService {
constructor (private _authToken: string) {
}
}
The APIService
class has been defined, and the constructor accepts one argument; _authToken
. But because we have declared private
before the parameter name, we no longer have to define _authToken
again as a class property; a private _authToken
property will automatically be assigned by the constructor.
This saves us from writing repetitive syntax, as would be the case had automatic constructor parameter assignment not existed:
// the alternative to ACPA: lots of repetition
export class APIService {
private _authToken: string;
constructor (_authToken: string) {
this._authToken = _authToken;
}
}
This becomes very useful if your constructor handles two or three parameters, saving space and keeping syntax readable.
Now an auth token has been defined via the constructor, APIService
can now be instantiated in App.tsx
, like so:
// App.tsx
...
import { APIService} from './APIService';
const App: React.FC = () => {
const api = new APIService('my-super-secret-auth-token');
...
}
Note: It goes without saying that security credentials should never be hard coded. Retrieve them from your environment or a file that has not been committed to source control. For the sake of simplicity, we have hard coded a dummy auth token here.
Getters and setters
We may need to get or set a different auth token in subsequent API requests, giving us the opportunity to demonstrate getters and setters in Typescript. Within APIService
, we can add them directly under constructor()
:
// getter
get authToken (): string {
return this._authToken;
}
// setter
set authToken (newAuthToken: string) {
this._authToken = newAuthToken;
}
Notice the get
and set
keywords before the function name; these keywords automatically configure the method scope; they need to be public
in order to be called externally. The getter simply returns a string
, this._authToken
, whereas the setter overwrites this._authToken
with newAuthToken
.
The cleaner syntax is also carried over to the instantiated object. Calling a getter
or a setter
inherits the syntax of a class property more-so than calling a method; there are no parenthesis and no arguments:
const api = new APIService('my-super-secret-auth-token');
// calling the setter
api.authToken = 'new-secret-authtoken';
// calling the getter
if (api.authToken) {
console.log('remember to remove this before building');
console.log(api.authToken);
}
With auth token handling now integrated, let’s now move onto handling request headers.
Configuring Request Headers
Request headers vary between web services and are commonly used for security credentials, therefore it is important that our API service can easily customise headers sent for each request. Let’s explore some ways we can do that.
Generic Key Value Type
Let’s think of a way we can bring headers into APIService
, and how APIService
will store those headers. The ideal scenario would be:
- If we could explicitly give each header name and value to
APIService
as it is instantiated - If
APIService
could then store those headers in a way thatfetch()
understands — an associative array ofstring => string
Firstly tackling ideal scenario 1, we need to define an APIService
method that accepts an arbitrary number of headers and store them in a private class property.
A typical header may look like the following:
'Accept' => 'application/json'
Headers are of type string => string
, therefore, we could opt to define a header type to reflect exactly this:
// candidate for a header type
export type ApiHeader = {
key: string;
value: string;
};
And this would be a valid type; using an ApiHeader[]
type would represent an array of headers to be assigned to APIService
.
However, if we look closely, something much more generic is happening here — ApiHeader
is actually replicating a common key => value
pattern, that a whole range of objects could conform to. What we could do instead is create a generic type and simply call it KeyValue
, like so:
// types.ts
export type KeyValue<T, U> = {
key: T,
value: U,
};
Now we have a re-usable type that is not strictly limited to two string
types. The T
and U
type variables can represent any type to be assigned to key
and value
.
Note: As mentioned earlier, my Typescript generics explained introductory article explains exactly what generics are and how they are used.
Now with the KeyValue
type defined, we can go ahead and create a method for APIService
that accepts an array of KeyValue
objects representing our headers. This is what that signature and return type looks like:
public setHeaders (headers: KeyValue<string, string>[]): APIService {
return this;
}
setHeaders
is not a setter — it is a public function, that expects one argument: headers
. headers
is a KeyValue
array of string
and string
. T
and U
have now been assigned a type of string
and string
respectively, within angled brackets, followed by an array declaration []
.
Concretely, setHeaders
is expecting a KeyValue
array of string
and string
to be passed in as headers
. What does this look like when actually calling the method? Let’s expand our instantiation in App.tsx
to also define some headers:
// App.tsx
const api = new APIService('my-super-secret-auth-token')
.setHeaders([
{
key: 'Accept',
value: 'application/json'
},
{
key: 'Content-Type',
value: 'application/json'
},
]);
As setHeaders
expects, we have provided an array of headers consisting of objects that confirm to KeyValue<string, string>
. In this particular example I have opted for JSON content headers, commonly used for JSON based web services.
Now, setHeaders
is also being chained to api
, and this is where our fluent interfaces come into play.
Fluent interface
As demonstrated above, setHeaders
is chained to api
after new APIService()
, via a period. This is possible because the class constructor returns the instance of the class. What we did not touch on with setHeaders()
is its return type: APIService
. This type is referring to the class prototype itself, allowing the method to return the class instance conforming to that prototype.
To keep chaining methods, each subsequent method must also return the class instance, which setHeaders()
does indeed do. The following sums up this requirement:
// every chained method must return `this`
const obj = new Obj()
.setSomething() < returns this
.setSomethingElse() < returns this
.setAnotherThing(); < returns this
APIService
uses one more chained method for the request delivery method itself, and consists of an ApiMethod
typed parameter of either "GET"
or "POST"
, that is then persisted in a private _method
class property:
// _method implementation summary
export type ApiMethod = "POST" | "GET";
private _method: ApiMethod = "POST";
public setMethod (newMethod: ApiMethod): APIService {
this._method = newMethod;
return this;
}
Included in APIService
is also a setter for the private _method
property.
Note: The ApiMehod
type is more formally referred to as a string literal type, giving its valid type values as strings separated by|
Setting headers as string[ ][ ]
To wrap up our setHeaders
implementation, we need to loop through the KeyValue
array we provided, and store those headers in APIService
as a private property.
The issue we have now is that the fetch()
API does not expect our headers to be of type KeyValue<string, string>[]
. It instead expects a string[][]
, or simply an associative array of two strings.
This is not an issue — we can indeed loop through headers
and then push()
each to a private _headers
class property, reformatting them to an associative array along the way:
// APIService.ts
private _headers: string[][] = [];
public setHeaders (headers: KeyValue<string, string>[]): APIService {
for (const i in headers) {
if (headers[i].hasOwnProperty('key')
&& headers[i].hasOwnProperty('value')) {
this._headers.push([
headers[i].key,
headers[i].value
]);
}
}
return this;
}
This for loop is arguably a lot of boilerplate, but it ensures that a key
and a value
property is provided within our headers
objects; if syntax errors occur for example, this loop will not break. Each key
and value
is re-formatted into an array, and appended to the private _headers
class property.
_headers
itself is initialised as an empty array by default; a null
or undefined
value would not be valid as they do not conform to the type of string[][]
.
Other methods for _headers
APIService
also provides a getter for _headers
, allowing us to retrieve the value externally, as well as a public resetHeaders()
method that, as the name suggests, wipes all saved headers:
get headers (): string[][] {
return this._headers;
}
public resetHeaders (): void {
this._headers = [];
}
Recall that setHeaders()
simply appends new headers to _headers
, so we need to introduce a separate means of resetting them in the case they are no longer needed — that’s exactly what resetHeaders()
does.
The RequestBody class
In addition to APIService
, APIService.ts
actually includes another class specifically for handling a fetch request body — or the data to be sent along with the request.
This class is generic, allowing us to define any type required for a request body. The class itself is only a few lines of code. Let’s see what it consists of, highlighting key areas of interest:
// APIService.ts
export class RequestBody<T> {
constructor (private _requestBody: T) {
}
get requestBody (): T {
return this._requestBody;
}
set requestBody (newRequestBody: T) {
this._requestBody = newRequestBody;
}
}
This class consists of one private property: _requestBody
. Generally speaking, the body of a request is very wide ranging; it could represent an _id
to fetch a particular record, or an authorName
to fetch all articles by a particular writer — we simply cannot tell what type _requestBody
will be at the prototype level, so we turn to generics.
The generic T
type variable here is defined at the class level, and then assigned as the return type of the requestBody
getter and setter respectively. What might T
look like? In the aforementioned authorName
scenario, we could instantiate a RequestBody
like so, tailoring a type for the request body specifically for querying articles:
type ArticleCategory = "Typescript" | "React" | "Rust";
type ApiRequestAuthor = {
author: string,
category: ArticleCategory,
};
const body = new RequestBody<ApiRequestAuthor>({
author: 'ross',
category: "Typescript",
});
Our generic RequestBody
class is now set up in a way that allows us to type any request body. No any
types needed!
What’s left to cover now is actually making a fetch()
request, and handling the response that would either result in a success or a failure.
Formatting and Calling fetch()
Let’s briefly recap what fetch()
requires in order to make a request:
fetch('/endpoint/here',
{
headers: {
'Accept': 'application/json',
...
},
method: "POST",
body: JSON.stringify({
...
})
})
.then(res => res.json())
.then(data => {
// handle response
})
.catch(() => {
// handle request error
});
APIService
and RequestBody
have already catered for the bold content of the above fetch request. In fact, APIService
has another public function, request()
, whose purpose is to package up this data in a format fetch()
understands. Here is the implementation:
// APIService.ts
public request<T> (body: T): RequestInit {
return {
headers: this._headers,
method: this._method,
body: JSON.stringify(body),
}
}
request()
is a generic function, where type variable T
caters for the request body type provided in RequestBody<T>
. It takes requestBody
as an argument and formats all our predefined data into an object fetch()
understands. The return type, RequestInit
, is the type fetch()
expects for this object.
Note: To obtain types for native Javascript functions, hover over the function in your editor to obtain the implemented types. Here is the signature for fetch()
for example:

Let’s update the fetch request boilerplate from above, replacing the bolded configuration with api.request()
:
// App.tsx
fetch('/endpoint/here', api.request(body))
.then(res => res.json())
.then(data => {
// handle response
})
.catch(() => {
// handle request error
});
api.request()
already takes away most of the boilerplate, letting us focus on handling the response.
Handling the response
Now let’s draw our attention to the successful response block, where we have JSON formatted data
to handle. A response object will vary depending on the service, but let’s assume a successful response will yield the following JSON:
{
Result: "success" | "failure",
Response: {
// this can either be what we requested, or an error message
}
}
Result
is a high level code of success
or failure
, a common convention with web services. Response
will therefore either consist of an ApiError
object, or an object with the data we originally requested. Typescript can handle this setup with ease. Take a look at the following implementation:
export type ApiResult = "success" | "failure";
export type ApiError = {
ErrorCode: string,
Description: string,
};
export type ApiResponse<T> = {
Result: ApiResult,
Response: T | ApiError,
};
These types embody most of the features we have worked with throughout the article:
ApiResult
is a string literal type of the response codes we can expect to receiveApiError
handles a failed request, where a more verbose code and description can be provided. This will obviously vary between web services, but can nevertheless be configured as a type.ApiResponse<T>
is what the response object is typed as. We can expect aResult
code, followed by aResponse
object that can either be anApiError
, or an object consisting of the data of a successful request, the type of which we do not know, hence a genericT
is used.
These types can now be applied to our response data
object. In this case, a ArticleResponse
type has been used in place of T
to cater for the successful response object:
// App.tsx
fetch('/endpoint/here', api.request(body))
.then(res => res.json())
.then(data => {
const response: ApiResponse<ArticleResponse> = data;
// do something with `response`
})
.catch(() => {
// handle request error
});
And with that successful response, the vast majority of our API service solution has been covered.
Summary
This article has covered a range of Typescript features used in conjunction with an API service manager. Here is a brief recap of some of those features used:
- The concept of a fluent interface, allowing methods to be chained together
- Getters and setters, of which Typescript provide minimal syntax
- Automatic constructor parameter assignment, that prevents repetition of class properties
- Generic types such as
KeyValue<T, U>
, abstracting over common design patterns. - Reformatting objects to adhere to other types, as the private
_headers
property did forfetch()
, that expects astring[][]
type for headers. - Use of generic classes such as
RequestBody<T>
, where the type of request object is unknown at compile time. Using generics in this way allows us to type request data with any type, without resorting toany
. - String literal types, a convenient way to provide a range of strings as valid type values
Once again, the full project is freely available on Github to coincide with this walkthrough.
If you are looking for more example use cases of Typescript, I have also published a live chat solution in a two part series. The first part documents the backend solution, whereas part two explores the front end client: