Type-safe API calls with Laravel and TypeScript

Leo Sjöberg • July 9, 2024

Working at incident.io, I’ve really come to appreciate our auto-generated API client. Whenever we make changes to our API, we do so by making a change to the API spec (in Go), which is used to generate an OpenAPI spec, which in turn is used to generate a typescript client that uses fetch by utilising openapi-generator. That client can then be used to make fully type-checked API calls, without having to memorise request parameters and API paths, or going back and forth between API and frontend code to construct the right request.

While writing a bit of Laravel over the weekend, where I was building out a NextJS-powered single page app with a Laravel-based API, I got frustrated with not having the convenience and type safety of an auto-generated API client. I had to keep going back to my PHP files to remember what should go in the request, nor did I have types for the data being returned. So I started investigating how I might go about getting a similar development experience with Laravel.

What I wanted

The experience we have at incident.io feels pretty great. You write a definition like this:

1var User = CommonType("User", func() {
2 Attribute("id", String, "Unique ID for the user", func() {
3 Example("01FCNDV6P870EA6S7TK1DSYDG0")
4 })
5 Attribute("name", String, "The user's name", func() {
6 Example("Leo")
7 })
8 Attribute("email", String, "The user's email address", func() {
9 Example("[email protected]")
10 })
11 
12 Required("id", "name", "email")
13})
14 
15Method("Self", func() {
16 Payload(func() {
17 Attribute("user", User, "the current user")
18 })
19})

And you end up writing code like this:

1const { data } = useAPI("identitySelf", undefined)
2// ^?
3// data {
4// user: {
5// id: string
6// name: string
7// email: string
8// }
9// }

You get a fully typed API client and request and response types, which your IDE can then autocomplete in your frontend codebase when you make API calls.

The approach

To achieve this, I knew I’d have to string together a few different tools:

  1. Generate an OpenAPI spec from Laravel’s routes, along with the request and response data structures
  2. Using the OpenAPI spec and openapi-generator, generate a TypeScript client
  3. Write a tanstack-query wrapper around the generated TypeScript client

Generating an OpenAPI spec

At first, I thought I’d have to build this on my own. I’d realised that in order to generate an OpenAPI spec, I’d need class properties for the data on my request and response objects, as that would enable using reflection to figure out what the data structure would look like (list routes, reflect the controller class, reflect the method, reflect the method argument and return type, list properties).

Through my friend Hakan, I discovered spatie’s laravel-data package. The Data package is meant to replace form requests, resources, or any other data structure, in a richer, type-safe way:

1class UserData extends Data {
2 public function __construct(
3 public string $id,
4 public string $name,
5 #[Email()]
6 public string $email,
7 ) {}
8}

The library will automatically infer some validation rules based on type data, like inferring that it should be a string if the property’s type is string, or inferring required if the property is not marked optional. With attributes, you can also specify validation rules, just like you would in Laravel form requests.

Defining the Laravel route

Next, we can set up a Laravel route like usual, with the addition of the Data package typed data structure:

1// routes/api.php
2Route::get('/self', [IdentityController::class, 'self'])->middleware('auth:sanctum');
3 
4// IdentityController.php
5class IdentityController
6{
7 public function self(): UserData
8 {
9 return UserData::from(Auth::user());
10 }
11}

I then went searching for a way to generate an OpenAPI spec from this. After all, if this library exists, surely someone else must’ve already had the same idea! And I was right – in the documentation for the library, Spatie links out to laravel-data-openapi-generator on GitHub, a package that generates an OpenAPI spec from Laravel’s route information and Data classes. It requires type annotations or docblocks on controller methods, but will generate a reasonably good OpenAPI spec from just that.

Unfortunately, the library is a bit outdated and only supports V3 of the data library, whereas the current version is V4. Fortunately for me, someone else had opened a pull request just a couple days before I started looking at this, where they extended the library to produce an even better OpenAPI spec, and which was compatible with V4 of the library.

So I pulled the fork into my project, and we were off to the races! I was producing a fully valid OpenAPI spec.

I did this by adding the fork to my composer.json's repositories section:

1"repositories": [
2 {
3 "type": "github",
4 "url": "https://github.com/thisisdevelopment/laravel-data-openapi-generator"
5 }
6]

And then requiring that specific branch:

1composer require xolvio/laravel-data-openapi-generator:dev-feature/laravel-data-v4

You can then generate the OpenAPI schema with an artisan command:

1php artisan openapi:generate

Generating a TypeScript client

This bit was pretty straightforward. There’s an excellent tool called openapi-generator which does all that I need. While you can download the tool to your laptop, they also have a Docker image you can use to do the generation, which I opted to use for portability (and to avoid installing yet another tool on my laptop).

Since I want to generate this into my NextJS app, I’ll want to run the generator from the root of my monorepo, so I can easily access both the API (Laravel app) and dashboard (NextJS app) codebases. My directory structure in the monorepo looks like this

1.
2├── Caddyfile
3├── Makefile
4├── app
5└── server

To generate the TypeScript client, I run the following from the root:

1docker run --rm \
2 -v ${PWD}:/local openapitools/openapi-generator-cli generate \
3 -i /local/server/resources/api/openapi.json \
4 -g typescript-fetch \
5 -o /local/app/api/gen

This generates a client into api/gen inside my NextJS app.

At this point, you have a fully typed API client ready to use!

1const client = new DefaultApi()
2const self = await client.apiSelfGet()
3 
4console.log(self)
5// {"id": "01J1N5GNPZNDQ7ZCKX8KNQ2W60", "name": "Leo", "email": "[email protected]"}

Wrapping in TanStack Query

The only thing left to do was wrapping the client in TanStack Query to have a nice way of calling my API in React.

Doing this is pretty straightforward - since the openapi generator generates a single service in OpenAPI called DefaultApi, it’s very easy to generate a callback. There are some complicated types, but the code itself is just wrapping our client:

1type ApiType = InstanceType<typeof DefaultApi>
2 
3export function useAPI<
4 TApi extends Exclude<keyof ApiType, keyof BaseAPI>,
5 TApiMethod extends ApiType[TApi],
6 TFetcher extends TApiMethod extends (req: TRequest) => Promise<TResponse>
7 ? TApiMethod
8 : never,
9 TRequest extends Parameters<TApiMethod>[0],
10 TResponse extends Awaited<ReturnType<ApiType[TApi]>>,
11>(method: TApi, req: TRequest) {
12 const { client } = useClient();
13 
14 const fetcher = client[method].bind(client) as TFetcher
15 
16 return useQuery({
17 queryKey: [method],
18 queryFn: () => {
19 return fetcher(req)
20 },
21 });
22}

A lot of this is inspired from the code we have at incident.io to do something very similar, but adapted for tanstack query (we use swr at work).

While it looks complex, you only really have to look at it once. TApi is the type representing the API methods’ names. It takes all keys of the DefaultApi class, and excludes all properties from the BaseAPI class (which has methods like withMiddleware), which is what DefaultApi extends, meaning we’re left with only the real API methods.

TRequest is then the type of the request object, and TResponse the type of the response, which is what lets us get type checks for the request and response, based on which API endpoint is specified in the first argument.

All that ties together into this:

1const { data } = useAPI("apiSelfGet", undefined)

With one line of code in my React component, I now get a fully typed object back, with autocompletion both for the client method and response. For any requests that have parameters, whether query parameters in a GET request, or the body in a POST/PUT request, those will have generated types too.

The rough edges

While this all feels pretty great, there are a few things I wish I could get:

Better named API methods

At incident.io, our API methods get actual names, based on a service name and method name, meaning, in our generated client, we call things like identitySelf (identity service, self method).

In the client I get generated from Laravel, they’re just named after the HTTP verb and path, e.g apiSelfGet for GET /api/self.

Descriptions and examples on methods and parameters

Since we describe the API with Goa at incident.io, we’re able to do much more “advanced” OpenAPI things, like provide examples and descriptions for all parameters. I’ve not found a way to do that with Laravel, which means using this for a public API is not quite as viable.

A more accurate useAPI implementation

So far, I’ve not found a way to overload the type definition to make it possible to just call apiSelfGet(), despite the fact that the method accepts no arguments.

I think it should be possible, but my TypeScript isn’t good enough for it.

Despite the little rough edges, this speeds up my development workflow significantly, and makes for a much more seamless developer experience!