Leveraging Laravel, Inertia, Vue.js, and TypeScript for Full-Stack Type Safety

May 27, 2024 (2mo ago)

Despite working on numerous SaaS (Software-as-a-Service) ventures over the years, it wasn't until I started building Analyse, an analytics platform for game servers, that I began to realise how crucial end-to-end type safety is.

I would often hear about TypeScript from friends and "Tech Twitter", but I would often hesitate to use it as it felt like an extra step and I saw no need. I mean, I know what data I'm passing through, why do I need to spend extra time adding types to all my code?

But as the development of Analyse started, I was dealing with a lot more database models than any other SaaS project I'd worked on before. It became frustrating that I couldn't get auto-completion and type-hints from my code editor for these models, and I needed to refer back to my schema or models often to be sure I typed the column correctly.

It didn't help that I got the daunting squiggly lines from my code editor, "Property name does not exist on type unknown". Sigh.. but it does exist though, I know it does!

A typical setup

Consider a scenario where you have a User model (I mean, who doesn't have users, right?), You'll typically pass this data from the backend of your Laravel app to your Vue.js component with Inertia.js like so..

routes/web.php
Route::get('/user/{user}', function (User $user) {
    return inertia('Dashboard', [
        'user' => $user
    ]);
});

While this works, it’s not ideal. You'll need to constantly refer back to your User model in Laravel to know what properties are available. There’s also a risk of accidentally exposing sensitive data like tokens, passwords etc. Not what we're looking for!

This is where Laravel Data comes in

Not all super heroes wear capes, others use Laravel Data instead! This package allows you to create data classes that act as simple containers for your data. We can install this package using composer:

composer require spatie/laravel-data spatie/laravel-typescript-transformer

Once it's installed, we can create a UserData class by running the following command:

php artisan make:data UserData

Then we can modify our new class to look something similar to this:

app/Data/UserData.php
use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
 
#[TypeScript]
class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public bool $is_admin,
    ) {}
}

Wrapping our User model with UserData

Now that we've create a dedicated data class for our user model, let's modify our user route to use the new class - notice how we are wrapping our model using the ::from syntax.

routes/web.php
Route::get('/user/{user}', function (User $user) {
    return inertia('Dashboard', [
        'user' => UserData::from($user)
    ]);
});

Yippee! Now we're only passing the id, name, email, and is_admin properties to the front-end. No more data leakage!

But, what about TypeScript..?

Don't worry, let's talk about that! Any eagle eyed viewer may have spotted the #[TypeScript] attribute in our new UserData class. This attribute allows us to generate TypeScript types automatically using the spatie/laravel-typescript-transformer package.

Let's run the following command:

php artisan typescript:transform

By running this command, it will generate the TypeScript type for UserData like so:

resources/js/types/generated.d.ts
declare namespace Data {
    export type UserData = {
        id: number;
        name: string;
        email: string;
        is_admin: boolean;
    };
}

Now going back to our Vue.js component, we can define a type hint for our user prop using the TypeScript type we just generated. Let's modify our Vue component to look like so:

resources/js/Pages/Dashboard.vue
<script lang="ts" setup>
defineProps({
    user: {
        type: Object as PropType<Data.UserData>,
        required: true,
    },
});
</script>

Now if we access the user prop in our component, we'll get auto-completion and type hints for the id, name, email, and is_admin properties. No more squiggly lines!

What about relationships?

Hold up, this isn't an article about dating! Hoping of course that we are talking about models here, let's say that a user can create posts, we would have a posts relationship. We can also create a PostData class and to wrap the Post model. This gives us control over which data is passed to the front-end and ensure type safety.

Let's create our PostData class to look like so:

app/Data/PostData.php
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
 
#[TypeScript]
class PostData extends Data
{
    public function __construct(
        public int $id,
        public string $title,
        public string $content,
        public int $user_id,
    ) {}
}

Then amend the UserData class to include the posts relationship:

app/Data/UserData.php
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
use App\Data\PostData;
 
#[TypeScript]
class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public bool $is_admin,
        #[DataCollectionOf(PostData::class)]
        public PostData $posts,
    ) {}
}

This would now generate the following TypeScript type for UserData:

resources/js/types/generated.d.ts
export type UserData = {
    id: number;
    name: string;
    email: string;
    is_admin: boolean;
    posts: PostData[];
};
 
export type PostData = {
    id: number;
    title: string;
    content: string;
    user_id: number;
};

Now we can access the posts relationship in our Vue component with full type safety. Like so:

resources/js/Pages/Dashboard.vue
<template>
    <div>
        <h1>{{ user.name }}</h1>
        <ul>
            <li v-for="post in user.posts" :key="post">
                <h2>{{ post.title }}</h2>
                <p>{{ post.content }}</p>
            </li>
        </ul>
    </div>
</template>
 
<script lang="ts" setup>
defineProps({
    user: {
        type: Object as PropType<Data.UserData>,
        required: true,
    },
});
</script>

Taking it further with model permissions

I'm sure you're already blown away by everything so far, but what if I told you we could take this a step further by adding permissions to our models? Let's say you want the delete button to only be shown to users who can delete that post. Easy!

Install the based/momentum-lock package using composer:

composer require based/momentum-lock

Then extend your data classes from the existing Data class provided by Laravel Data to a DataResource like so:

app/Data/PostData.php
use Momentum\Lock\Data\DataResource;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
 
#[TypeScript]
class PostData extends DataResource
{
    public function __construct(
        public int $id,
        public string $title,
        public string $content,
        public int $user_id,
    ) {}
}

Finally, register the DataResourceCollector in the TypeScript Transformer configuration file like so:

config/typescript-transformer.php
return [
    'collectors' => [
        Momentum\Lock\TypeScript\DataResourceCollector::class,
        Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,
        Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptCollector::class,
    ],
]

Now we can use the can method in our Vue component to check if the user can delete the post:

resources/js/Pages/Post.vue
<template>
    <div>
        <h1>{{ post.title }}</h1>
        <p>{{ post.content }}</p>
        <button v-if="can(post, 'delete')">Delete</button>
    </div>
</template>
<script lang="ts" setup>
defineProps({
    user: {
        type: Object as PropType<Data.UserData>,
        required: true,
    },
    post: {
        type: Object as PropType<Data.PostData>,
        required: true,
    },
});
</script>

Voila, that's it! You now have full type safety across your full-stack application. :tada:

Hope it helps you as much as it has helped me! Feel free to reach out to me on Twitter if you have any questions.