Type safety from backend to frontend with Laravel & Remix

Using Remix on the frontend and Laravel is my favourite way to make maintainable web apps.

(I've written about why here)

This means there is a pretty distinct boundary between my frontend and backend and they both need to understand how to interact with each other over the API.

There are plenty of ways of defining an API schema. I've looked into OpenAPI/Swagger, tRPC and a few others and in the end, I decided to make my own lightweight approach.

This approach works for any JavaScript/TypeScript frontend but I'll explain how I use it with Remix more specifically. You get the most benefit from this if your frontend uses TypeScript but even if you use JavaScript, having autocomplete in your terminal for types for your API is a real productivity boost.

In a nutshell

  1. Define data objects in PHP: Create a PHP class with properties that match what you will send and receive via the API e.g. PostData
  2. Generate TypeScript types: There is an excellent package that takes that class and generates the equivalent TypeScript type
  3. Use the types in Remix: Import them and use them for sweet, sweet type safety

Step 1. Define data objects in PHP

Ruben Van Assche's spatie/laravel-data package is excellent. It provides a really ergonomic way of defining Data Transfer Objects.

If you're familiar with Laravel, it also basically rolls the concepts of Form Requests, DTOs, and JSON Resources all into one class.

Here's how you might define a post data object:

<?php

namespace App\Data;

use DateTime;
use Spatie\LaravelData\Data;

class PostData extends Data
{
    public function __construct(
        public ?int $id,
        public string $title,
        public string $content,
        public ?DateTime $created_at,
        public ?DateTime $updated_at,
    ) {
    }

    public static function rules(): array
    {
        return [
          'title' => ['string', 'max:100', 'required'],
          'content' => ['string', 'required'],
        ];
    }
}

It's worth noting I like to mark id, created_at and updated_at as optional so that I can use the same data object for creating, updating and reading a model. As in, when creating or updating a post you only need to provide the title and content fields but when reading you will get all of the fields.

Step 2. Generate TypeScript types

Ruben Van Assche's spatie/typescript-transformer package is also excellent. It takes PHP classes and generates TypeScript type definitions.

It's very quick and easy to install and configure. Once it's set up, we can run php artisan typescript:transform and it will generate a declaration file like this:

export type PostData = {
  id: number | null;
  title: string;
  content: string;
  created_at: string | null;
  updated_at: string | null;
};

Step 3. Use the types in Remix

This step will depend on your setup. You may be able to import { FormData } from "../backend/src/types/generated.d.ts";. The way I have have my monorepo set up, I can import them directly from "backend". My Remix code looks something like this:

// Post Route: app/routes/posts/$postId.tsx
import { json, LoaderArgs, ActionArgs } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
import { PostData } from "backend";

// Utility type to make all object properties non-null
type AllProps<Data> = {
  [K in keyof Data]: NonNullable<Data[K]>;
};

const API_ENDPOINT = "http://localhost:8000";

export async function loader({ params }: LoaderArgs) {
  const post = (await fetch(`${API_ENDPOINT}/posts/${params.postId}`).then(
    (response) => response.json()
  )) as AllProps<PostData>;
// `AllProps<>` is needed here to make the `id`, `created_at` and `updated_at` fields non null.

  return json({
    post,
  });
}

export async function action({ request, params }: ActionArgs) {
  const formData = await request.formData();

  const title = formData.get("title");
  const content = formData.get("content");

  // Ideally perform validation here

  const body: PostData = {
    title,
    content,
  };

  await fetch(`${API_ENDPOINT}/posts/${params.postId}`, {
    method: "put",
    body,
  });

  return null;
}

export default function Post() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article>
      {/* show the post */}
      <Form method="post">
        {/* inputs for editing the post go here */}
      </Form>
    </article>
  );
}

And that's it

Now, every time you update your PostData data object in your Laravel app you can run an Artisan command to regenerate your types and keep your frontend in sync.

One thing it is lacking is that you have to manually annotate the type coming back from your API. That's the as AllProps<PostData> in the loader function in the example above. Remix can then infer the return type of the loader so you can make use of the types defined in Laravel all in your component.

That leaves an opportunity for error but once you have it set up and working, how often are you going to entirely change the data object that your API returns rather than just modifying it?

In my next post I'll show how I autogenerate a type safe API client that knows based on the API endpoint I call what the return type will be.

If that sounds interesting to you, follow me on Twitter to see when it's posted.

Thanks for reading,

cEmvZW2CA.png.webp