Generete type for Frontend from OpenApi/Swagger

Pongsatorn Manusopit
4 min readJun 24, 2024

--

I already have a Data Transfer Object (DTO) defined on my backend and have integrated OpenApi/Swagger. Still, I am required to write data types on the frontend — a task that can be redundant and potentially error-prone. Thus, I have been exploring methods to generate these data types directly from OpenApi/Swagger to streamline the development process.

TL;DR

I utilize openapi-typescript to generate types from Swagger and then create a fetch function using these types. See the last section for the function code.

TypeScript types

First, I need some methods to convert Swagger into TypeScript. So, I utilize openapi-typescript, which facilitates the generation of TypeScript types from fixed OpenAPI schemas.

You can install it along with TypeScript, as a development dependency like so

npm i -D openapi-typescript typescript

Next, add a script to your package.json file to facilitate easy generation of TypeScript interfaces:

"scripts": {
"gen:interface": "openapi-typescript https://backend:3000/swagger-json -o ./interfaces/interface.ts"
}

Right now you can use type from your backend

import { components } from "@/interface/interface"

export type OrderListDto = components["schemas"]["OrderListDto"]

Utilize more

Taking into consideration the types we have, we can develop a function that facilitates auto-suggestion, as well as ensuring type safety in auto request and response.

You can jump to the last part for the completed function.

// start
function fetchToBackend(url: string, options: any) {

}

Enhance URL Auto Suggestion

//The below generated type ./interfaces/interface.ts (partial)
export interface paths {
"/user/update-profile": {
post: operations["UserController_updateProfile"]
}
"/user/upload-avatar": {
get: operations["UserController_getUploadAvatarPresignedUrl"]
}
"/user/{username}": {
get: operations["UserController_getPublicUserInfo"]
}
}

Here, it’s clear that our ‘paths’ interface represents each accessible route, we add a generic type from ‘paths’ and employ it on the URL parameter.

import { paths } from "./interface/interface"

async function fetchToBackend<K extends keyof paths>(url: K, options: any) {

}

so we get URL suggestion from that

Enforce request body type safe

//The below generated type ./interfaces/interface.ts (partial)
export interface paths {
"/user/update-profile": {
post: operations["UserController_updateProfile"]
}
}

export interface operations {
UserController_updateProfile: {
requestBody: {
content: {
"application/json": components["schemas"]["UpdateUserDto"]
}
}
responses: {
201: {
content: never
}
}
}
}

export interface components {
schemas: {
UpdateUserDto: {
displayName?: string
bio?: string
}
}
}

So we need UpdateUserDto for our request body

//This will extract type for DTO and allow any key for flexibility
type CustomRequestInit<K extends keyof paths, M extends keyof paths[K]> = Omit<RequestInit, "body"> & {
body?: Extract<
paths[K][M],
{ requestBody: { content: { "application/json": Record<string, any> } } }
>["requestBody"]["content"]["application/json"] & { [key: string]: any }
}

//If you want to allow on key in DTO use this
//type CustomRequestInit<K extends keyof paths, M extends keyof paths[K]> = Omit<RequestInit, "body"> & {
// body?: Extract<
// paths[K][M],
// { requestBody: { content: { "application/json": Record<string, any> } } }
// >["requestBody"]["content"]["application/json"]
//}

async function fetchToBackend<K extends keyof paths, M extends keyof paths[K]>(
url: K,
options: CustomRequestInit<K, M>
& { method: M })
{

}

const resp = fetchToBackend("/user/update-profile",{body:{}})

this custom type will extract the DTO that we need

Add support for URL params

type ExtractParams<K extends string> = K extends `${infer L}{${infer P}}${infer R}` ? P | ExtractParams<R> : never

async function fetchToBackend<K extends keyof paths, M extends keyof paths[K]>(
url: K,
options: CustomRequestInit<K, M>
& { method: M }
& ([ExtractParams<K>] extends [never]? {} : { params: Record<ExtractParams<K>, any> }))
{

}

now we have type force when URL require params

Add argument for any URL query

// add querys?: Record<string, any>
async function fetchToBackend<K extends keyof paths, M extends keyof paths[K]>(
url: K,
options: CustomRequestInit<K, M>
& { method: M; querys?: Record<string, any> }
& ([ExtractParams<K>] extends [never] ? {} : { params: Record<ExtractParams<K>, any> })) {

}

const resp = fetchToBackend("/user/{username}", { method: "get", params: { username: "Test" }, querys: { test: "test" } })

Enhance response type

async function fetchToBackend<K extends keyof paths, M extends keyof paths[K]>(
url: K,
options: CustomRequestInit<K, M>
& { method: M; querys?: Record<string, any> }
& ([ExtractParams<K>] extends [never] ? {} : { params: Record<ExtractParams<K>, any> })
): Promise<Extract<
paths[K][M],
{ responses: { 200: { content: { "application/json": Record<string, any> } } } }
>["responses"][200]["content"]["application/json"] extends never
? { [key: string]: any }
: Extract<
paths[K][M],
{ responses: { 200: { content: { "application/json": Record<string, any> } } } }
>["responses"][200]["content"]["application/json"] & { [key: string]: any }>
{
return {} as any
}

We arrived at this solution to extract specific types from the interface. However, we believe there’s still room for improvement. We appreciate any insights or suggestions for enhancing this section of our coding strategy.

Completed function

async function fetchToBackend<K extends keyof paths, M extends keyof paths[K]>(
url: K,
options: CustomRequestInit<K, M>
& { method: M; querys?: Record<string, any> }
& ([ExtractParams<K>] extends [never] ? {} : { params: Record<ExtractParams<K>, any> })
): Promise<Extract<
paths[K][M],
{ responses: { 200: { content: { "application/json": Record<string, any> } } } }
>["responses"][200]["content"]["application/json"] extends never
? { [key: string]: any }
: Extract<
paths[K][M],
{ responses: { 200: { content: { "application/json": Record<string, any> } } } }
>["responses"][200]["content"]["application/json"] & { [key: string]: any }>
{
//add you login here

//Example
//1. construct options for request
//2. replace params on url
//3. add query params
//4. make request to backend
return {} as any
}

const resp = fetchToBackend("/user/{username}", { method: "get", params: { username: "Test" }, querys: { test: "test" } })

Reference

Helped by https://www.linkedin.com/in/sittikorn-hir/

--

--