How I keep my typescript front ends in sync with my non-typescript backends, using the open api spec
If you're a full-stack developer working entirely within the TypeScript ecosystem, for example, using a React frontend talking to a Node.js backend, using tools like tRPC, this post might not be for you, because achieving type safety is practically instant and somewhat trivial. You rename a field on the server, hit save, and your frontend instantly throws an error, catching bugs before they even leave your desk and you have a quick and effective feedback loop.
However for someone like myself who has had to work on a split tech stack, the feedback loop is not that great. For example, if your frontend is written in TypeScript, but your backend is built with something like .NET, FastAPI (Python), e.t.c, you suddenly lose that immediate safety net. That gap between your server code and your client code becomes a potential source of frustrating, runtime-only bugs.
This lack of instant type synchronization is a source of friction. I don’t know if there are better ways to handle this, but the best reliable solution I’ve found that works across different tech stacks is leveraging the OpenAPI specification.
In the rest of this post, I'll elaborate on how we can use the OpenAPI spec generated by your backend to automatically create perfectly synchronized, type-safe clients for your frontend.
The Process
Modern API frameworks, including .NET Core with Swashbuckle, automatically generate an OpenAPI Specification. This is like a complete blueprint of your entire API, down to the exact field names, data types, and required parameters.
Shout out to the original maker of the package open-api-typescript-code-gen which is the tool I would typically use to build the frontend API client from my back end API. However while writing this post, I found that the owner has advised users of the package to migrate to a different one now due to the fact that he can no longer keep up with its maintenance. In the repo here, which I generated using claude code (and reviewed 😊) I have used this new package and will describe the important parts. The process basically takes three steps:
Backend Writes the Contract: .NET (or your chosen framework) generates the OpenAPI spec.
Frontend Reads the Contract: We run a simple script to generate TypeScript files from the spec.
Frontend Stays Safe: Your app uses the newly generated, type-safe API client with full IntelliSense.
How is this better ?
A typical API call in a frontend involves directly executing an HTTP request to a specific URL endpoint. This process forces the developer to manually track the endpoint path, the HTTP method, the required parameters, and the expected shape of the data in relation to the schema. This creates boilerplate and is highly prone to human error, requiring constant manual synchronization of types. For example:
import axios from 'axios';
// Manually defining a type is necessary but prone to being outdated.
interface User { /* ... */ }
// The typical manual API function
const fetchUserManual = async (userId: number) => {
// You must hardcode the URL, method, and know the ID placement
const response = await axios.get(`http://localhost:5000/api/Users/${userId}`);
return response.data;
}
// Example usage:
const handleFetch = async (id: number) => {
// Developer must remember the function name, parameters, and return type.
try {
const user = await fetchUserManual(id);
console.log(user.firstName); // No guarantee that 'firstName' exists until runtime
} catch (error) {
// ...
}
}
The OpenAPI code generation process completely abstracts this complexity. It creates a dedicated Service Layer of TypeScript functions. Your frontend simply calls a function (e.g., UsersService.getUser(id)), and the generated client handles all the underlying HTTP details, path definitions, and most importantly, provides compile-time type safety for both the input and the output. Assuming your backend API is running and ready for consumption, this can be achieved by :
Installing the hey-api/openapi-ts package . ( Instructions here )
Setting up your config, so the package knows where to grab the types and where to output the generated services. ( Instructions here ) . In the repo for this blog post, the configuration can be found in the root of the front end app at the following relative path frontend/openapi-ts.config.ts
Adding a script to your package.json that you can execute everytime to generate the build. In my case I used :
"generate:api": "openapi-ts"Running the script e.g
npm run generate:api
The output from running the script would generate the service layer, and this can be used in the following way:
Generated Service Layer - See frontend/src/api/services.ts and frontend/src/api/sdk.gen.ts
/// This code is generated from your OpenAPI spec.
import type { User } from './types.gen';
export class UsersService {
// Example of a generated function to fetch a single user
public static async getApiUsers1(id: number): Promise<User> {
// Under the hood, this calls the low-level, generated HTTP client
// and handles path variables, headers, and response parsing automatically.
const response = await getApiUsersById({ path: { id } });
return response.data as User;
}
// Example of a generated function to create/update a user
public static async postApiUsers(requestBody: User): Promise<User> {
const response = await postApiUsers({ body: requestBody });
return response.data as User;
}
}
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { DeleteApiProductsByIdData, DeleteApiProductsByIdResponses, DeleteApiUsersByIdData, DeleteApiUsersByIdResponses, GetApiProductsByIdData, GetApiProductsByIdResponses, GetApiProductsData, GetApiProductsResponses, GetApiUsersByIdData, GetApiUsersByIdResponses, GetApiUsersData, GetApiUsersResponses, PostApiProductsData, PostApiProductsResponses, PostApiUsersData, PostApiUsersResponses, PutApiProductsByIdData, PutApiProductsByIdResponses, PutApiUsersByIdData, PutApiUsersByIdResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
export const getApiUsers = <ThrowOnError extends boolean = false>(options?: Options<GetApiUsersData, ThrowOnError>) => (options?.client ?? client).get<GetApiUsersResponses, unknown, ThrowOnError>({ url: '/api/Users', ...options });
export const postApiUsers = <ThrowOnError extends boolean = false>(options?: Options<PostApiUsersData, ThrowOnError>) => (options?.client ?? client).post<PostApiUsersResponses, unknown, ThrowOnError>({
url: '/api/Users',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
export const deleteApiUsersById = <ThrowOnError extends boolean = false>(options: Options<DeleteApiUsersByIdData, ThrowOnError>) => (options.client ?? client).delete<DeleteApiUsersByIdResponses, unknown, ThrowOnError>({ url: '/api/Users/{id}', ...options });
export const getApiUsersById = <ThrowOnError extends boolean = false>(options: Options<GetApiUsersByIdData, ThrowOnError>) => (options.client ?? client).get<GetApiUsersByIdResponses, unknown, ThrowOnError>({ url: '/api/Users/{id}', ...options });
export const putApiUsersById = <ThrowOnError extends boolean = false>(options: Options<PutApiUsersByIdData, ThrowOnError>) => (options.client ?? client).put<PutApiUsersByIdResponses, unknown, ThrowOnError>({
url: '/api/Users/{id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
Front end (calling the service layer) - The core frontend component can then call the backend through the service layer.
import { UsersService } from '../api'
import type { User } from '../api' // Importing the perfectly synced User type
function UserFormPage() {
// ... state and setup ...
const fetchUser = async (userId: number) => {
try {
setLoading(true)
// ✅ Generated client: Simple, functional call
// TypeScript knows exactly what 'user' will be (a User object)
const user = await UsersService.getApiUsers1(userId)
setFormData(user)
} catch (err) {
// ...
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// ...
try {
setLoading(true)
if (isEditMode && id) {
// ✅ TypeScript ensures 'formData' (which must match 'User')
// and 'id' are passed correctly.
await UsersService.putApiUsers(parseInt(id), formData as User)
} else {
await UsersService.postApiUsers(formData as User)
}
navigate('/users')
} catch (err) {
// ...
} finally {
setLoading(false)
}
}
// ... rest of component
}
I would usually not have a direct call like this in the component. I prefer to use a library like tanstack query to extend functionality like caching. Thus, the calls would typically exist in a separate location, abstracted away. However for the purpose of this post, the code should suffice. The working application is hosted here.
Hopefully someone finds this helpful as a way to work around the problem of keeping types in sync across distributed architectures. If you have other neat ways to do this across a diverse stack, please share.
Till next time. Cheers