<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tayo Adegoke]]></title><description><![CDATA[Tayo Adegoke]]></description><link>https://blog.tech.tayoadegoke.com</link><generator>RSS for Node</generator><lastBuildDate>Fri, 22 May 2026 14:16:18 GMT</lastBuildDate><atom:link href="https://blog.tech.tayoadegoke.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I keep my typescript front ends in sync with my non-typescript backends, using the open api spec]]></title><description><![CDATA[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 i...]]></description><link>https://blog.tech.tayoadegoke.com/how-i-keep-my-typescript-front-ends-in-sync-with-my-non-typescript-backends-using-the-open-api-spec</link><guid isPermaLink="true">https://blog.tech.tayoadegoke.com/how-i-keep-my-typescript-front-ends-in-sync-with-my-non-typescript-backends-using-the-open-api-spec</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[OpenApi]]></category><category><![CDATA[React]]></category><category><![CDATA[dotnet]]></category><dc:creator><![CDATA[Tayo Adegoke]]></dc:creator><pubDate>Sat, 22 Nov 2025 14:29:47 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h3 id="heading-the-process">The Process</h3>
<p>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.</p>
<p>Shout out to the original maker of the package <a target="_blank" href="https://www.npmjs.com/package/openapi-typescript-codegen">open-api-typescript-code-gen</a> 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 <a target="_blank" href="https://github.com/hey-api/openapi-ts">different one now</a> due to the fact that he can no longer keep up with its maintenance. In t<a target="_blank" href="https://github.com/tayoadegoke/blogPost-openapi-sync-fullstack-dotnet-react">he repo here</a>, 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:</p>
<ol>
<li><p><strong>Backend Writes the Contract:</strong> .NET (or your chosen framework) generates the OpenAPI spec.</p>
</li>
<li><p><strong>Frontend Reads the Contract:</strong> We run a simple script to generate TypeScript files from the spec.</p>
</li>
<li><p><strong>Frontend Stays Safe:</strong> Your app uses the newly generated, type-safe API client with full IntelliSense.</p>
</li>
</ol>
<h3 id="heading-how-is-this-better">How is this better ?</h3>
<p>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:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">'axios'</span>;

<span class="hljs-comment">// Manually defining a type is necessary but prone to being outdated.</span>
<span class="hljs-keyword">interface</span> User { <span class="hljs-comment">/* ... */</span> }

<span class="hljs-comment">// The typical manual API function</span>
<span class="hljs-keyword">const</span> fetchUserManual = <span class="hljs-keyword">async</span> (userId: <span class="hljs-built_in">number</span>) =&gt; {
  <span class="hljs-comment">// You must hardcode the URL, method, and know the ID placement</span>
  <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> axios.get(<span class="hljs-string">`http://localhost:5000/api/Users/<span class="hljs-subst">${userId}</span>`</span>);
  <span class="hljs-keyword">return</span> response.data;
}

<span class="hljs-comment">// Example usage:</span>
<span class="hljs-keyword">const</span> handleFetch = <span class="hljs-keyword">async</span> (id: <span class="hljs-built_in">number</span>) =&gt; {
  <span class="hljs-comment">// Developer must remember the function name, parameters, and return type.</span>
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> fetchUserManual(id);
    <span class="hljs-built_in">console</span>.log(user.firstName); <span class="hljs-comment">// No guarantee that 'firstName' exists until runtime</span>
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-comment">// ...</span>
  }
}
</code></pre>
<p>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., <code>UsersService.getUser(id)</code>), 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 :</p>
<ol>
<li><p>Installing the hey-api/openapi-ts package . ( <a target="_blank" href="https://github.com/hey-api/openapi-ts?tab=readme-ov-file#installation">Instructions here</a> )</p>
</li>
<li><p>Setting up your config, so the package knows where to grab the types and where to output the generated services. ( <a target="_blank" href="https://heyapi.dev/openapi-ts/configuration">Instructions here</a> ) . 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 <a target="_blank" href="https://github.com/tayoadegoke/blogPost-openapi-sync-fullstack-dotnet-react/blob/main/frontend/openapi-ts.config.ts"><em>frontend/openapi-ts.config.ts</em></a></p>
</li>
<li><p>Adding a script to your package.json that you can execute everytime to generate the build. In my case I used :</p>
<pre><code class="lang-json"> <span class="hljs-string">"generate:api"</span>: <span class="hljs-string">"openapi-ts"</span>
</code></pre>
</li>
<li><p>Running the script e.g <code>npm run generate:api</code></p>
</li>
</ol>
<p>The output from running the script would generate the service layer, and this can be used in the following way:</p>
<p><strong>Generated Service Layer</strong> - See <a target="_blank" href="https://github.com/tayoadegoke/blogPost-openapi-sync-fullstack-dotnet-react/blob/main/frontend/src/api/services.ts"><em>frontend/src/api/services.ts</em></a> <em>and</em> <a target="_blank" href="https://github.com/tayoadegoke/blogPost-openapi-sync-fullstack-dotnet-react/blob/main/frontend/src/api/sdk.gen.ts"><em>frontend/src/api/sdk.gen.ts</em></a></p>
<pre><code class="lang-typescript"><span class="hljs-comment">/// This code is generated from your OpenAPI spec.</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { User } <span class="hljs-keyword">from</span> <span class="hljs-string">'./types.gen'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> UsersService {
  <span class="hljs-comment">// Example of a generated function to fetch a single user</span>
  <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">async</span> getApiUsers1(id: <span class="hljs-built_in">number</span>): <span class="hljs-built_in">Promise</span>&lt;User&gt; {
    <span class="hljs-comment">// Under the hood, this calls the low-level, generated HTTP client</span>
    <span class="hljs-comment">// and handles path variables, headers, and response parsing automatically.</span>
    <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> getApiUsersById({ path: { id } });
    <span class="hljs-keyword">return</span> response.data <span class="hljs-keyword">as</span> User;
  }

  <span class="hljs-comment">// Example of a generated function to create/update a user</span>
  <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">async</span> postApiUsers(requestBody: User): <span class="hljs-built_in">Promise</span>&lt;User&gt; {
    <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> postApiUsers({ body: requestBody });
    <span class="hljs-keyword">return</span> response.data <span class="hljs-keyword">as</span> User;
  }
}


<span class="hljs-comment">// This file is auto-generated by @hey-api/openapi-ts</span>

<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Client, Options <span class="hljs-keyword">as</span> Options2, TDataShape } <span class="hljs-keyword">from</span> <span class="hljs-string">'./client'</span>;
<span class="hljs-keyword">import</span> { client } <span class="hljs-keyword">from</span> <span class="hljs-string">'./client.gen'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { DeleteApiProductsByIdData, DeleteApiProductsByIdResponses, DeleteApiUsersByIdData, DeleteApiUsersByIdResponses, GetApiProductsByIdData, GetApiProductsByIdResponses, GetApiProductsData, GetApiProductsResponses, GetApiUsersByIdData, GetApiUsersByIdResponses, GetApiUsersData, GetApiUsersResponses, PostApiProductsData, PostApiProductsResponses, PostApiUsersData, PostApiUsersResponses, PutApiProductsByIdData, PutApiProductsByIdResponses, PutApiUsersByIdData, PutApiUsersByIdResponses } <span class="hljs-keyword">from</span> <span class="hljs-string">'./types.gen'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> Options&lt;TData <span class="hljs-keyword">extends</span> TDataShape = TDataShape, ThrowOnError <span class="hljs-keyword">extends</span> <span class="hljs-built_in">boolean</span> = <span class="hljs-built_in">boolean</span>&gt; = Options2&lt;TData, ThrowOnError&gt; &amp; {
    <span class="hljs-comment">/**
     * 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.
     */</span>
    client?: Client;
    <span class="hljs-comment">/**
     * 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.
     */</span>
    meta?: Record&lt;<span class="hljs-built_in">string</span>, unknown&gt;;
};



<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> getApiUsers = &lt;ThrowOnError <span class="hljs-keyword">extends</span> <span class="hljs-built_in">boolean</span> = <span class="hljs-literal">false</span>&gt;<span class="hljs-function">(<span class="hljs-params">options?: Options&lt;GetApiUsersData, ThrowOnError&gt;</span>) =&gt;</span> (options?.client ?? client).get&lt;GetApiUsersResponses, unknown, ThrowOnError&gt;({ url: <span class="hljs-string">'/api/Users'</span>, ...options });

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> postApiUsers = &lt;ThrowOnError <span class="hljs-keyword">extends</span> <span class="hljs-built_in">boolean</span> = <span class="hljs-literal">false</span>&gt;<span class="hljs-function">(<span class="hljs-params">options?: Options&lt;PostApiUsersData, ThrowOnError&gt;</span>) =&gt;</span> (options?.client ?? client).post&lt;PostApiUsersResponses, unknown, ThrowOnError&gt;({
    url: <span class="hljs-string">'/api/Users'</span>,
    ...options,
    headers: {
        <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span>,
        ...options?.headers
    }
});

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> deleteApiUsersById = &lt;ThrowOnError <span class="hljs-keyword">extends</span> <span class="hljs-built_in">boolean</span> = <span class="hljs-literal">false</span>&gt;<span class="hljs-function">(<span class="hljs-params">options: Options&lt;DeleteApiUsersByIdData, ThrowOnError&gt;</span>) =&gt;</span> (options.client ?? client).delete&lt;DeleteApiUsersByIdResponses, unknown, ThrowOnError&gt;({ url: <span class="hljs-string">'/api/Users/{id}'</span>, ...options });

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> getApiUsersById = &lt;ThrowOnError <span class="hljs-keyword">extends</span> <span class="hljs-built_in">boolean</span> = <span class="hljs-literal">false</span>&gt;<span class="hljs-function">(<span class="hljs-params">options: Options&lt;GetApiUsersByIdData, ThrowOnError&gt;</span>) =&gt;</span> (options.client ?? client).get&lt;GetApiUsersByIdResponses, unknown, ThrowOnError&gt;({ url: <span class="hljs-string">'/api/Users/{id}'</span>, ...options });

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> putApiUsersById = &lt;ThrowOnError <span class="hljs-keyword">extends</span> <span class="hljs-built_in">boolean</span> = <span class="hljs-literal">false</span>&gt;<span class="hljs-function">(<span class="hljs-params">options: Options&lt;PutApiUsersByIdData, ThrowOnError&gt;</span>) =&gt;</span> (options.client ?? client).put&lt;PutApiUsersByIdResponses, unknown, ThrowOnError&gt;({
    url: <span class="hljs-string">'/api/Users/{id}'</span>,
    ...options,
    headers: {
        <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span>,
        ...options.headers
    }
});
</code></pre>
<p><strong>Front end (calling the service layer)</strong> - The core frontend component can then call the backend through the service layer.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { UsersService } <span class="hljs-keyword">from</span> <span class="hljs-string">'../api'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { User } <span class="hljs-keyword">from</span> <span class="hljs-string">'../api'</span> <span class="hljs-comment">// Importing the perfectly synced User type</span>

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">UserFormPage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-comment">// ... state and setup ...</span>

  <span class="hljs-keyword">const</span> fetchUser = <span class="hljs-keyword">async</span> (userId: <span class="hljs-built_in">number</span>) =&gt; {
    <span class="hljs-keyword">try</span> {
      setLoading(<span class="hljs-literal">true</span>)
      <span class="hljs-comment">// ✅ Generated client: Simple, functional call</span>
      <span class="hljs-comment">// TypeScript knows exactly what 'user' will be (a User object)</span>
      <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> UsersService.getApiUsers1(userId) 
      setFormData(user)
    } <span class="hljs-keyword">catch</span> (err) {
      <span class="hljs-comment">// ...</span>
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>)
    }
  }

  <span class="hljs-keyword">const</span> handleSubmit = <span class="hljs-keyword">async</span> (e: React.FormEvent) =&gt; {
    e.preventDefault()
    <span class="hljs-comment">// ...</span>
    <span class="hljs-keyword">try</span> {
      setLoading(<span class="hljs-literal">true</span>)
      <span class="hljs-keyword">if</span> (isEditMode &amp;&amp; id) {
        <span class="hljs-comment">// ✅ TypeScript ensures 'formData' (which must match 'User') </span>
        <span class="hljs-comment">// and 'id' are passed correctly.</span>
        <span class="hljs-keyword">await</span> UsersService.putApiUsers(<span class="hljs-built_in">parseInt</span>(id), formData <span class="hljs-keyword">as</span> User) 
      } <span class="hljs-keyword">else</span> {
        <span class="hljs-keyword">await</span> UsersService.postApiUsers(formData <span class="hljs-keyword">as</span> User)
      }
      navigate(<span class="hljs-string">'/users'</span>)
    } <span class="hljs-keyword">catch</span> (err) {
      <span class="hljs-comment">// ...</span>
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>)
    }
  }
  <span class="hljs-comment">// ... rest of component</span>
}
</code></pre>
<p>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 <a target="_blank" href="https://blogpost-frontend-966066459395.europe-west2.run.app/">hosted here.</a></p>
<p>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.</p>
<p>Till next time. Cheers</p>
]]></content:encoded></item></channel></rss>