Full-stack static typing with OpenAPI TypeScript and Microsoft.AspNetCore.OpenApi

John Reilly January 2, 2026
Source
I've long believed in the benefits of static typing. Static typing helps you catch errors early, improves code navigation and makes refactoring easier. In recent years I've been using TypeScript on the front end and Con the back end to get these benefits. I wrote previously about how to do this with NSwag and I thought it was probably worth returning to the topic. How would I do the same thing now? NSwag is still great, but it produces OpenAPI 3.0 specifications. However, Microsoft have been working on their own OpenAPI tooling for .NET. The Microsoft.AspNetCore.OpenApi package provides functionality to generate OpenAPI specifications from ASP.NET Core Web APIs and it supports OpenAPI 3.1. This difference turns out to be significant when it comes to handling nullability. There was a change to how nullablity is represented in OpenAPI 3.1 compared to 3.0. Whether that change is the cause or not I'm not sure, but the OpenAPI specifications produced by Microsoft.AspNetCore.OpenApi seem to surface nullability better than I've found with NSwag or Swashbuckle. If something is not defined as nullable in the Cmodel, it is not marked as nullable in the OpenAPI spec. This means that when we generate TypeScript clients from the OpenAPI spec, we get better nullability support in TypeScript too. Previously I'd find I'd do a lot of null checks or assertions in TypeScript even when the Cmodel didn't allow nulls. Now, with OpenAPI 3.1 and Microsoft.AspNetCore.OpenApi, I find that much less often. The client that NSwag generates is also still very useful. But it is somewhat "heavy" in that it creates a lot of code, and it is runtime code, so it adds to my bundle size and my execution time. The alternative I'm going to show you here is to use OpenAPI TypeScript / openapi-ts. This is a lightweight TypeScript client generator for OpenAPI 3.x specifications. Most of the work it does is in the form of TypeScript type definitions. Given that type definitions are erased at runtime, the resulting client code is very lightweight. It also has good support for OpenAPI 3.1. What will we do? So in this post we're going to do exactly what I did in my 2021 post, but this time using Microsoft.AspNetCore.OpenApi to generate the OpenAPI spec and openapi-ts to generate the TypeScript client. We will: - Create a .NET app which exposes an OpenAPI endpoint with Microsoft.AspNetCore.OpenApi. - Create a script which, when run, creates a TypeScript client with openapi-ts. - Consume the API using the generated client in a simple TypeScript application. If you're going to do this, you will need both Node.js and the .NET SDK installed. Create an API We'll now create an API which exposes an Open API endpoint: The above command creates a new .NET Web API project in a folder called server. Pretty much all the code we care about is in Program.cs: This is simply exposing a single endpoint, /weatherforecast which returns some (fake) weather data. If we run our API with: We can then navigate to http://localhost:5000/weatherforecast and see the JSON output: And we can see the OpenAPI endpoint at http://localhost:5000/openapi/v1.json: This is great! (Actually, there's some problems with the temperatureC and temperatureF properties being marked as both integer and string but we'll ignore that for now.) Create our client We'll now create a web app with which to consume our API: This creates a React + TypeScript app in a folder called client. We'll now follow the openapi-ts setup instructions to add openapi-ts to our project: And we'll update the tsconfig.app.json to include the recommended settings: To make local development easier, we'll also add a proxy to our vite.config.ts so that API request is proxied to our .NET API: Now we no longer need to deal with CORS during development, and our local development setup more closely resembles production. Incidentally, we could put all our API requests behind the proxy if we wanted to by using a standard prefix like /api, but for this demo we'll just proxy the one endpoint. We have a front end app ready to consume our API. But we need to generate an OpenAPI client first. Generate our OpenAPI client We'll add an npm script to our package.json in the client folder to generate our OpenAPI client using openapi-ts: This, when run, will generate a TypeScript client in src/GeneratedClient.ts based on the OpenAPI spec exposed by our .NET API. It will also include the "root types" so we can import them in our code easily. To generate the client, we need to ensure our API is running. So we'll jump back up to the root of our .NET / React project and we'll add a package.json. We'll add the following two dependencies: Then we'll add scripts to handle running client and server together, and to generate the client: Running npm run generate-client in the root of our project will now: - Start the server API on http://localhost:5000 - Wait for the OpenAPI endpoint to be available using start-server-and-test - Run the generate-client script in the client folder to generate the TypeScript client. Here's what our generated client looks like: You can see our /weatherforecast endpoint is represented in the paths section and the WeatherForecast model is represented in the components.schemas section. Adjusting Microsoft.AspNetCore.OpenApi surfaced types I mentioned earlier that the temperatureC and temperatureF properties were marked as both integer and string in the OpenAPI spec. This is because Microsoft.AspNetCore.OpenApi is being ... interesting ... about number types. If we look at the types created in our client we see: Note how temperatureC and temperatureF are both number | string. This isn't what we're after; we want them to be just number to reflect the Cint model. To fix this, we can create 2 IOpenApiSchemaTransformer implementations to fix up the number | string types to just number types. One to handle integer style numbers (IntegerSchemaTransformer) and one to handle numbers with decimal places (NumberSchemaTransformer). And the Program.cs is updated to register these transformers: With this in place, when we next run npm run generate-client from the root of our project, we find that our generated client now has the correct types for temperatureC and temperatureF: I've inquired whether the default behaviour makes the most sense here. Consume our generated API client Now we want to make use of our generated client in our React app. First we're going to install openapi-fetch to help with making requests: (A quick note, openapi-fetch is not strictly necessary here, but it makes things easier. It provides a fetch-based HTTP client which works well with openapi-ts generated clients. It's worth saying that there are plans to deprecate openapi-fetch which you can read about here. As of right now though, it's still a useful library to use alongside openapi-ts.) Now let's start our client and server with npm run start. We'll then replace the contents of App.tsx with: Let's break down what's happening here: - We import the generated types from GeneratedClient.ts - We create an openapi-fetch client using those types. - In a useEffect hook, we call the /weatherforecast endpoint using the generated client. From a users perspective, when we run the app we see: (I've reused the GIF from my previous post here as the experience is the same.) Summary In this post we've seen how to create a .NET Web API which exposes an OpenAPI endpoint using Microsoft.AspNetCore.OpenApi. We've then seen how to generate a TypeScript client from that OpenAPI spec using openapi-ts. Finally, we've seen how to consume that generated client in a React + TypeScript application. What's significant here is that we have static typing all the way from back end to front end. The Cmodels we defined in our .NET API are represented in the OpenAPI spec, and those same models are represented in TypeScript types in our front end application. This means that if we change a model on the back end, we can regenerate the TypeScript client and get type safety on the front end too. I'm using C#, but you could be using something else entirely on the back end, as long as it can produce an OpenAPI spec. There was a little adjustment needed to get the number types working correctly, but overall this was a pretty straightforward process. If you're building full stack applications with TypeScript on the front end and .NET on the back end, I recommend giving this approach a try!

Discussion in the ATmosphere

Loading comments...