The examples in this post are available in a demo repository here: https://github.com/liavzi/custom-open-api-ts-client.
The Motivation
In one of the projects I'm working on, we use a simple API service to communicate with the server:
export class ApiService {
private baseUrl = '';
constructor() {
}
get(endpoint: string): Observable<any> {
...
}
post(endpoint: string, body: any: Observable<any> {
...
}
}
// when I need to use it
apiService.get("iHateToCopyThisEveryTime").subscribe(response: AnotherTypeINeedToManuallyCreateEveryTime) => {});
The first problem is that I always have to manually pass the endpoint URL. This usually means copy pasting it from the backend, which is repetitive and easy to mess up.
The second problem is even worse: whenever I need to GET or POST json data, I also need to manually create a matching TypeScript interface that represents the server’s request or response model. This is tedious and error prone. If someone changes a property name or adds a new field on the server and forgets to update the client, things break silently.
So the goal became clear: automatically generate a TypeScript client. Whenever an API endpoint is added or changed on the server, the client should get a matching, fully typed function, automatically. No more copy-pasting URLs, no more mismatched interfaces, and no more guessing. Just calling strongly typed functions that feel like any other regular TypeScript function.
Before Diving In
- I’m using ASP.NET Core on the backend and Angular on the frontend, but the general idea works with almost any tech stack.
- Here, I’m using the Hey API package to generate TypeScript models. This package can also generate full Angular, React, and other clients. However, I ran into a challenge: I wanted to integrate it into an existing project that already had a service handling API calls. Merging the two wasn’t straightforward, so I decided to use Hey API solely for model generation.
The Plan
- Generate an OpenAPI specification automatically on every server build.
- Generate the request and response models from that spec using Hey API.
- Generate matching client API services,one for each controller on the server, so the frontend always has up-to-date, fully typed functions.
Generate an OpenAPI specification automatically on every server build
ASP.NET Core supports OpenAPI out of the box, so generating the specification is pretty straightforward:
using Backend.OpenApi;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi("internal-api", options =>
{
options.AddOperationTransformer(new AddMethodNameOperationTransformer());
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseAuthorization();
app.MapControllers();
app.Run();
Pay special attention to the following line, which adds a custom operation transformer:
options.AddOperationTransformer(new AddMethodNameOperationTransformer());
and the code of the transformer:
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Backend.OpenApi;
public class AddMethodNameOperationTransformer : IOpenApiOperationTransformer
{
public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
if (context.Description.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
return;
operation.AddExtension("x-method-name", new JsonNodeExtension(controllerActionDescriptor.ActionName));
}
}
Thanks to the transformer, the generated OpenAPI spec will now include the actual C# method name as part of the operation metadata. This becomes extremely helpful when generating the TypeScript client, because we can produce clean, predictable function names instead of trying to infer them from routes.
For example, consider an API endpoint that returns a list of books:
[HttpGet]
public IEnumerable<Book> GetAllBooks()
{
return Books;
}
It appears in the generated JSON like this:
"/api/Books": {
"get": {
"tags": [
"Books"
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Book"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Book"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Book"
}
}
}
}
}
},
"x-method-name": "GetAllBooks"
},
Now we want to generate the OpenAPI JSON on every build.
ASP.NET Core makes this straightforward. We can simply follow the official instructions from Microsoft’s documentation.
After that, we add the following lines to the .csproj file:
<PropertyGroup>
<OpenApiDocumentsDirectory>$(ProjectDir)../../Frontend/src/app/contracts</OpenApiDocumentsDirectory>
</PropertyGroup>
<Target Name="CreateTypescriptClient" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
<Exec Command="npm run generate-contracts" WorkingDirectory="$(ProjectDir)../../Frontend" />
</Target>
This configuration instructs the build process to output the generated OpenAPI JSON into the client’s contracts directory.
Then, whenever the project is built in debug mode, it automatically runs the generate-contracts npm script, ensuring your TypeScript client stays in sync with the API definitions.
Client Side Model Generation
Next, we’ll implement the generate-contracts script using the Hey API package.
After installing HeyAPI, add the following scripts to your package.json:
"scripts": {
"ng": "ng",
"start": "ng serve",
...
"generate-contracts": "openapi-ts && npm run generate-api-services",
"generate-api-services": "node src/app/contracts/createApiServices.mjs"
},
The generate-contracts will now create types directly from the OpenAPI JSON.
Hey API is highly customizable, look at the official documantaion to to see how to configure it using the openapi-ts.config file.
Generate the Client's API Services
Now, here’s the really interesting part. The script createApiServices.mjs
iterates over the OpenAPI JSON and generates custom API services using ts-morph.
Take some time to explore the file and its comments to see exactly how it works under the hood.
For example, check out the BooksApiService, which is one of the services generated by this script:
import { Injectable } from '@angular/core';
import { ApiService, RequestParam } from '../../../api-service';
import { Observable } from 'rxjs';
import { Book } from '@contracts';
@Injectable({ providedIn: 'root' })
export class BooksApiService {
constructor(private readonly apiService: ApiService) {
}
getAllBooks(apiServiceRequestParams?: RequestParam): Observable<Book[]> {
return this.apiService.handleInternalApiCall({
url: "/api/Books",
pathParams: {},
queryParams: {},
httpVerb: "get",
requestBody: undefined,
apiServiceRequestParams
});
}
addBook(requestBody: Book, apiServiceRequestParams?: RequestParam): Observable<Book> {
return this.apiService.handleInternalApiCall({
url: "/api/Books",
pathParams: {},
queryParams: {},
httpVerb: "post",
requestBody: requestBody,
apiServiceRequestParams
});
}
getBookByTitle(title: string, apiServiceRequestParams?: RequestParam): Observable<undefined> {
return this.apiService.handleInternalApiCall({
url: "/api/Books/title/{title}",
pathParams: {title},
queryParams: {},
httpVerb: "get",
requestBody: undefined,
apiServiceRequestParams
});
}
getBooksByAuthor(author: string, apiServiceRequestParams?: RequestParam): Observable<Book[]> {
return this.apiService.handleInternalApiCall({
url: "/api/Books/author/{author}",
pathParams: {author},
queryParams: {},
httpVerb: "get",
requestBody: undefined,
apiServiceRequestParams
});
}
}
As you can see, it aligns perfectly with the server-side controller:
namespace Backend.Controllers
{
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
private static readonly List<Book> Books = new()
{
new Book { Title = "The Hobbit", Author = "J.R.R. Tolkien" },
new Book { Title = "1984", Author = "George Orwell" },
new Book { Title = "To Kill a Mockingbird", Author = "Harper Lee" }
};
[HttpGet]
public IEnumerable<Book> GetAllBooks()
{
return Books;
}
[HttpPost]
public Book AddBook([FromBody] Book book)
{
Books.Add(book);
return book;
}
[HttpGet("title/{title}")]
public Book? GetBookByTitle(string title)
{
return Books.FirstOrDefault(b => b.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
}
[HttpGet("author/{author}")]
public IEnumerable<Book> GetBooksByAuthor(string author)
{
return Books.Where(b => b.Author.Equals(author, StringComparison.OrdinalIgnoreCase));
}
}
public class Book
{
public required string Title { get; set; }
public required string Author { get; set; }
}
}
The amazing part is that this approach frees us from remembering URLs or building query strings - we can just call regular functions. Check out
books.component.ts to see how simple it is to use.
Some might argue that this couples the client to the server’s structure.
But in practice, for internal APIs, I don’t really care about URLs and query strings - they’re just implementation details. All I want is a straightforward way to call my server!
The final piece is implementing the ApiService.
The handleInternalApiCall(args: InternalApiCallArgs) method receives all the information needed to call the server, making integration with your existing project pretty straightforward. You can see my intentionally simplified implementation in the link above.
That’s it! With this setup, generating TypeScript clients from your OpenAPI spec is no longer a chore. You don’t have to worry about URLs, query strings, or boilerplate code - just call the API like a normal function.
Give it a try in your own projects and see how much smoother your development can become.
Top comments (0)