Skip to content
hm::dev
← all posts

Sharing types between a .NET API and a TypeScript frontend without duplicating them

updated 2026-08-29 · by Hagop Minassian

A client’s project pairs an ASP.NET Core API with a React frontend, one C# team and one TypeScript team, and for the first year the contract between them lived in a hand-maintained api-types.ts file on the frontend side. Someone would add a field to a C# DTO, remember to open the frontend repo, and copy the shape over by hand. It worked, until the person who changed the DTO wasn’t the person who remembered to update the file.

Where the hand-written interfaces broke

The backend added a new value to an order status enum, PartiallyRefunded, sitting between Refunded and Cancelled. The frontend’s hand-written type was a string union missing that value. Nothing failed to compile. TypeScript had no way to know the union was wrong, because nothing connected it to the C# enum it was supposed to mirror. The order just rendered with a blank status badge in production for three days before a support ticket flagged it.

That’s the actual failure mode with manually synced types. It’s not that developers are careless, it’s that a hand-written interface has no way to be wrong out loud. It compiles clean whether it matches the server or not.

Generating types from the OpenAPI spec instead

ASP.NET Core already had Swashbuckle wired up for Swagger UI, which meant a swagger.json was one endpoint away. The fix was running openapi-typescript against that spec as a build step, producing a generated api-schema.ts that nothing on the frontend team edits by hand. A short npm script pulls the spec from the local dev API (or a checked-in snapshot in CI, since the API isn’t always running when the frontend builds) and regenerates the file.

The generated types are uglier than the hand-written ones. openapi-typescript produces deeply nested paths['/orders/{id}']['get']['responses']['200']['content']['application/json'] style types before you alias them down to something readable. That aliasing step is the only hand-written part left, and it’s small enough to review in a pull request.

The mismatch it actually caught

Two weeks after switching over, a backend change to make a RefundedAt field nullable (DateOnly? instead of DateOnly) flowed straight into the generated type as string | null. Every place on the frontend that called .toLocaleDateString() on that field without a null check turned red in the editor. Under the old hand-written interface, that field would have stayed typed as a plain string, the null check would never have been written, and the crash would have shown up the first time an unrefunded order rendered.

That’s the actual value of generating types from a real contract instead of copying one by hand: the compiler only catches drift it can see, and a hand-written interface hides exactly the drift that matters.

What’s still manual

OpenAPI only describes REST endpoints. The project also has a SignalR hub pushing order updates over WebSockets, and there’s no spec format for that, so those event payloads are still a hand-written interface with the same silent-drift risk as before. The honest fix there is a shared contract test that fails the C# build if a hub method’s payload shape changes without a matching frontend update, which is still on the list. Generated types solved the REST half of the problem. The realtime half is exactly where I started.

Next
When Rust is worth it in a TypeScript-first stack