12-Dec

Type generation, Type safety

End-to-end type safety with Orval.js

Are you a part of a development team working with React, TypeScript, and REST APIs? If so, you've likely encountered the challenge of maintaining consistent data types across the applications.

5 min read

ยท

By Filip Christoffer Larsen

ยท

December 12, 2023

When it comes to API design and development, choices made may not always be obvious to other team members. Good documentation becomes essential, especially when you're not the sole consumer of your API.

Imagine a scenario where the backend automatically synchronizes with the frontend types, tedious tasks like manually creating types and reducing bugs caused by typos are eliminated, and the risk of breaking the frontend due to API changes is reduced. There are various methods to achieve these advantages, such as using Remix, GraphQL, or TypeScript monorepos. However, these may not be suitable options if your backend and frontend speak different languages.

In this article we will explore how you can ensure end-to-end type safety using a framework called Orval.js. The frontend is a React application written in TypeScript and the backend is a Spring-boot application written in Kotlin. However, any backend that can output an OpenAPI specification will work.

Spring Boot REST API With OpenAPI:

In the code example below we have a GET endpoint in the backend application, exposing a list of vampires from the series "What We Do in the Shadows".

open class VampireResource(
    private val vampireService: VampireService,
) {

@GET
@Path("/")
@Operation (summary = "Retrieve a list of vampires") fun getAllVampires(): List<VampireDTO> {
    return vampireService.getVampires()
    }
}

The Vampire DTO (Data Transfer Object) is a data class encapsulating specific attributes of a vampire, including basic types and some enumerated types. The objective is to present the data to the frontend in the form of a contractual agreement.

data class VampireDTO(
    val name: String,
    val age: Int,
    val abilities: List<Abilities>,
    val weaknesses: Weaknesses,
)

data class Abilities(
    val name: String,
    val castingTime: Long,
)
enum class Weaknesses {
    SUNLIGHT, GARLIC,
}

To achieve this, we need to set up an openAPI configuration in the backend application, using the framework SpringDoc to generate the documentation. By including SpringDoc as a dependecy in our project's build file (Maven or Gradle), we can structure the API layer with annotations that describes the API. Utilizing annotations like @Operation, @ApiResponse, and @Tags enhances the documentation with the endpoint request method, parameters, and expected responses.

"/vampires": {
  "get": {
    "tags": [
      "vampire"
    ],
    "summary": "Retrieve a list of vampires",
    "operationId": "getAllVampires",
    "responses": {
      "default": {
        "description": "default response",
        "content": {
          "application/json; charset=utf-8": {
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/VampireDTO"
              }
            }
          }
        }
      }
    }
  }
}

"Abilities": {
  "required": [
    "castingTime",
    "name"
  ],
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
    },
    "castingTime": {
      "type": "integer",
      "format": "int64"
    }
  }
},
"VampireDTO": {
  "required": [
    "abilities",
    "age",
    "name",
    "weaknesses"
  ],
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
    },
    "age": {
      "type": "integer",
      "format": "int32",
    },
    "abilities": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/Abilities"
      }
    },
    "weaknesses": {
      "type": "string",
      "enum": [
        "SUNLIGHT",
        "GARLIC"
      ]
    }
  },
}

Converting OpenAPI To TypeScript:

Now that the contract of the API is generated, we only need to install Orval.js and give it some config to read the specification:

  • Point to the OpenAPI specification in the Orval config.
  • Give the directory where you want to store your newly generated interfaces, types, and endpoints a name.
    • Orval.js supports different frameworks. In this example, we use a data-fetching library called TanStack-query / React-query.
  • Set the mode to react-query in the Orval configuration, instructing it to produce hooks based on the OpenAPI specification.
//orval.config.ts
import { defineConfig } from 'orval';


export default defineConfig({
  'app-name': {
    input: {
      target: '../doc/swagger.json'
    },
    output: {
      mode: 'tags',
      target: 'endpoints/default.ts',
      schemas: 'model',
      workspace: `tmp-api-generated/api-generated`,
      client: 'react-query',
      prettier: true,
      override: {
        mutator: {
          path: '../api/api.ts',
          name: 'generatedApi'
        }
      }
    },
  },
})


With the configuration set-up, the only thing remaining is to re-run the application, and observe the magic of end-to-end type generation.

/**
 * Generated by orval v6.17.0 ๐Ÿบ 
 * Do not edit manually.
 * OpenAPI spec version: */

import VampireDTOWeaknesses from './vampireDTOWeaknesses';
import Abilities from './Abilities';

export interface VampireDTO {
  name: string
  age: number
  abilities: Abilities[]
  weaknesses: VampireDTOWeaknesses
}

/**
 * Generated by orval v6.17.0 ๐Ÿบ
 * Do not edit manually.
 * OpenAPI spec version: */
export interface Abilities {
  name: string
  castingTime: number
}

/**
 * Generated by orval v6.17.0 ๐Ÿบ
 * Do not edit manually.
 * OpenAPI spec version:
 */
export type VampireDTOWeaknesses = typeof VampireDTOWeaknesses[keyof typeof VampireDTOWeaknesses]

// eslint-disable-next-line @typescript-eslint/no-redeclare
export const VampireDTOWeaknesses = {
  SUNLIGHT: 'SUNLIGHT',
  GARLIC: 'GARLIC',
} as const

Once Orval compiles, it reads the response types defined in the backend's OpenAPI Specification and generates a TypeScript interface for the VampireDTO. In the example, all the fundamental types, an interface representing a list of abilities, and the enumeration types were successfully created.

Since we included react query mode in the Orval configuration, it has also generated an appropriate GET request as a react query hook, including the correct return type and a query key for effective caching.

/**
 * Generated by orval v6.17.0 ๐Ÿบ
 * Do not edit manually.
 * OpenAPI spec version:
 */

import VampireDTO from './model/vampireDTO'
import generatedApi from '../../../api/api'

type AwaitedInput < T > = PromiseLike<T> | T

type Awaited<0> = 0 extends AwaitedInput<infer T> ? T : never
/**
 * @summary Retrieve a list of vampires
 */
export const getAllVampires = (signal?: AbortSignal) => {
  return generatedApi<VampireDTO[]>({ url: `/vampires`, method: 'get', signal })
}

export const getGetAllVampiresQueryKey = () => [`/vampires`] as const

Lastly, we are able to import the generated API hook useGetAllVampires() into the React component. This hook makes a http request to the backend, receiving a correctly typed list of VampireDTOs. Furthermore, as we traverse this list, TypeScript offers autocompletion for the available properties, along with their respective types.

React component using generated types and hook.

Why It's Useful:

Consistency: It ensures that the types and hooks accurately mirror the structure of your API, reducing the chances of mismatches between frontend and backend.

Efficiency: It saves time as developers don't need to manually write and update types and hooks when the API changes.

Error Reduction: It reduces bugs because the TypeScript compiler will catch type mismatches and errors at compile time.

Maintainability: Auto-generated code is easier to maintain, especially when APIs evolve, as changes in the backend are automatically reflected on the frontend.

Potential Drawbacks:

Customization: The automatically generated react query hooks may not always fit. Therefor, additional wrapper code or modification is necessary to handle specific use cases.

Abstraction level: Developers may become too reliant on the generated code, potentially overlooking the need to understand the underlying API or the data model. This could lead to difficulties when the generated code does not work as expected.

Security concerns: Introducing any kind of third party comes with a security risk. In this case we utilize multiple frameworks, and a security exploit in any of their code can have consequences for your codebase.

Conclusion:

Hopefully, this example illustrated some pros and cons of contract-based Hopefully, this example illustrates that Orval.js can bridge the gap between a backend and frontend application written in different languages. Type generating creates a single source of truth for API interactions and leads to a more streamlined development process, fewer runtime errors, and a more maintainable codebase.

If you want to read more about this topic, check out these articles: