Building a TypeScript SDK to communicate with your backend: Architecture and Implementation
I recently worked on a large-scale project involving multiple developers. To enhance the development experience, I decided to create a Software Development Kit (SDK) to standardize and streamline our processes. I built this SDK entirely in TypeScript as an internal package within our monorepo. However, its generic nature makes it suitable for use in other contexts or projects.

Elouan Savy
Jan 30, 2025
Enter focus mode
What is an SDK?
According to Amazon AWS, a Software Development Kit (SDK) is a set of platform-specific development tools for developers. In other words, an SDK typically handles consuming an API while abstracting away its complexity for the developer. This saves developers significant time, allowing them to focus on creating high-value features.
SDKs are often provided by the creators of the service being consumed. As a result, they already include best practices specific to the service and help standardize behaviors. However, they don’t always offer the same granular control as directly consuming the API.
Why Create an SDK?
Project Goals and Constraints
As mentioned earlier, I was motivated to create this SDK to establish a single source of truth for backend communication and to improve the development experience and feature creation speed. To fulfill its purpose, the SDK needed to:
- Enable request typing
- Handle user authentication
- Minimize developer errors
- Be usable in both web browser and mobile app contexts
Benefits
Creating such a utility offers several significant advantages. It simplifies testing and debugging since all backend communications originate from the same place and follow the same process. Additionally, I could fully leverage TypeScript to provide extensive auto-completion, reducing the likelihood of developer errors.
Project Architecture
My goal was to build a client that is as modular and generic as possible, ensuring it adapts to our evolving needs and remains usable in other projects or contexts.
To achieve this, I decided on the following structure:
Module and driver system used
In the diagram above, the red box represents the main class. It serves as the foundation and manages all the modules assigned to it.
The modules handle specific aspects of the SDK, as shown in the diagram. These modules can range from simple to complex, depending on the requirements. The idea is for them to be as autonomous as possible, adding functionality to our SDK.
Finally, to handle specific interactions based on execution contexts, modules can use different drivers. In my case, I needed to manage connection data persistence differently depending on the execution context.
Creating the SDK
The Client Class
The Client
class is the entry point of our SDK. It is instantiated and coordinates all the modules added to it. To allow the class to evolve alongside our backend, it expects a type during instantiation that describes all possible routes in the following format:
export interface Resources { api: { 'blog/post': { // <your_domain>/api/blog/post with method POST create: { body: { content: string; title: string }; }; // <your_domain>/api/blog/post with method GET get: { params: undefined }; }; 'blog/post/:uuid': { // <your_domain>/api/blog/post/<post_uuid> with method GET get: { params: { uuid: string } }; update: { body: { content: string; title: string }; params: { uuid: string }; }; delete: { params: { uuid: string }; }; }; }; };
With this type, we can create an instance of our class and obtain all the necessary information to communicate correctly with the backend.
The prototype of our main class is as follows:
export default class Client< Database, SchemaName extends string & keyof Database = 'api' extends keyof Database ? 'api' : string & keyof Database, Schema extends GenericSchema = Database[SchemaName] extends GenericSchema ? Database[SchemaName] : never, > {} // Class instantiation const client = createClient<Resources>({ domain: 'https://my_backend_domain.com' });
I admit this looks a bit overwhelming at first glance. But breaking it down, we get:
Database
corresponds to the type passed to the class during instantiation (in this case,Resources
).SchemaName
contains the key of the subpart of theResources
type we’re interested in (here,api
).- Finally,
Schema
contains the description of all requests within theapi
part of theResources
type.
Thanks to these generic types, the SDK can adapt automatically based on the Resources
type provided during instantiation.
In this class, we mainly find entry points to other modules, such as the QueryBuilder
, which I’ll discuss later:
from<ResourceName extends string & keyof Schema>(endpoint: ResourceName) { const authToken = this.auth.getToken(); return new QueryBuilder<Schema, ResourceName>(this._domain, endpoint, authToken); }
The Request Module
The first module I created is the request module. It serves as a utility for executing all SDK requests, whether to our backend or external APIs. The goal here is to create an abstract class usable by all other modules to standardize request execution and error handling.
Executing a Request
I chose to base all requests on the Fetch API since it’s available in all my execution contexts.
protected async _request({ url, method, body, headers, authToken }: RequestConfig): Promise<HTTPResponse> { // Execute the request const response = await fetch(url, { method, headers: this._mergeHeader(authToken, headers), body: this._getPayloadData(body), }); if (!response.ok) { // If the response status is not OK, throw a custom error throw new HttpClientRequestError({ msg: response.statusText, status: response.status, }); } const data = await response.json(); // Return the decoded data return { ok: true, status: response.status, data, }; }
As you can see, this function is straightforward. It executes the request and ensures everything goes smoothly.
Uniform Error Handling
The request module also provides an execution context to handle errors uniformly for all requests. The function looks like this:
protected async _safeRequest(f: () => Promise<HTTPResponse>) { try { return await f(); } catch (err) { const errorResponse: HTTPResponse = { ok: false, status: 500, error: String(err), }; if (err instanceof ClientError) { // Execute specific behavior linked to the error type const { status, msg, data, ok } = err.handle(); errorResponse.status = status; errorResponse.error = msg; errorResponse.data = data; errorResponse.ok = ok; } return errorResponse; } }
This function takes a simple request or a complete procedure as a parameter and handles all exceptions thrown during its execution. This ensures developers always receive data in the same format and can quickly identify issues by checking response.ok === true
.
Error Classes
The error handling in this SDK is based on an abstract class, ClientError
. It implements a handle
method to manage specific treatments based on the error type. The default behavior of the handle
method is to log the error and return its details.
For example, the HttpClientRequestError
class looks like this:
export class HttpClientRequestError extends ClientError { status: number; constructor({ msg, status }: HttpClientRequestErrorProps) { super({ msg, errorType: 'HttpRequestError' }); this.status = status; Object.setPrototypeOf(this, HttpClientRequestError.prototype); } }
Here, the class simply follows the default behavior of the handle
method. However, we could imagine requests or procedures requiring specific cleanup operations in case of failure, which could be implemented in the handle
method.
The Authentication Module
As the name suggests, this module handles all authentication-related tasks. It provides functions to simplify authentication flow implementation and persist user connection information.
We distinguish two main flows: the classic email/password flow and the OAuth2.0 flow. I won’t delve into the email/password flow as it’s straightforward and less interesting.
For OAuth2.0, I created a function that takes the provider configuration as a parameter. If the context is a browser, the function handles everything, including redirection. In a mobile app context, it simply returns the redirection URL, leaving it to the developer to handle platform-specific details.
signInWithOAuth(config: SignInWithOauthProvider) { const redirectProviderUrl = this._buildOAuthProviderUrl(config); if (typeof window !== 'undefined' && window.location) { window.location.assign(redirectProviderUrl); } return redirectProviderUrl; }
The Query Builder Module
This final module uses information from all the previous ones to enable seamless communication with our backend. Thanks to our prior work, we get auto-completion in the IDE and errors for incorrect parameters during request construction.
export class QueryBuilder< Schema extends GenericSchema, ResourceName extends string, Queries extends GenericQueries = Schema[ResourceName] extends GenericQueries ? Schema[ResourceName] : never, > extends BasicRequest {}
The QueryBuilder
class is instantiated using the same generic types as the main class, giving it access to all information about the Resources
type and, therefore, all possible requests and their parameters.
async request<RequestType extends string & keyof Queries>( type: RequestType, req: Queries[RequestType], options?: RequestOptions ) { // Here we have the safe request execution context return await this._safeRequest(async () => { // The endpointResolver function replaces the :uuid notation with the actual parameter value const url = this._endpointResolver(this._endpoint, (req as QueryData).params ?? {}); const { headers } = options ?? {}; return await this._request({ url, method: this._getMethodFormSchema(type), body: (req as QueryData).body, authToken: this._authToken, headers, }); }); }
This function is where the magic happens. It includes:
- The safe request execution context for uniform error handling
- The
endpointResolver
to replace URL parameters with their actual values (e.g.,/blog/post/:uuid
) - The request execution
When used in the IDE, we get:
Auto-completion of available routes
Auto-completion and errors for incorrect parameter types
Conclusion
I achieved a convincing result with a generic SDK that can easily be extended with new modules. After a few months of use, it has saved the team significant time and reduced code duplication. Moreover, it greatly minimizes errors related to backend communication.
Potential improvements include:
- Typing responses in addition to requests
- Automatically generating the
Resources
type using a CLI
If you have suggestions or want to discuss the implementation, feel free to reach out!