TypeScript & Vue 3: End-to-End Type Safety with Laravel

Learn how to achieve full-stack type safety by connecting Laravel APIs to Vue 3 frontends using TypeScript, generated types, and modern tooling.

Richard Joseph Porter
16 min read
typescriptvuelaravelapifrontendfull-stack

TypeScript is no longer optional for professional web development. According to the Stack Overflow Developer Survey 2025, 67.1% of professional developers now use TypeScript, with an 84.1% satisfaction rate. GitHub Octoverse 2025 revealed that TypeScript surpassed both Python and JavaScript to become the most used language by monthly contributors. For full-stack developers working with Laravel backends and Vue 3 frontends, the question is no longer whether to adopt TypeScript, but how to maximize its benefits across the entire stack.

This guide walks you through achieving end-to-end type safety between Laravel and Vue 3. We will cover generating TypeScript types from your Laravel API, building type-safe API clients, and handling validation errors with proper typing. By the end, you will have a development workflow where your IDE catches type mismatches before they reach production.

Why End-to-End Type Safety Matters

Traditional full-stack development involves a disconnect between backend and frontend types. You define a User model in Laravel with specific attributes, then manually create a matching TypeScript interface in Vue. When the backend changes, the frontend types become stale, leading to runtime errors that TypeScript was supposed to prevent. This challenge becomes even more critical in micro-frontend architectures where multiple teams work on different parts of the application.

End-to-end type safety eliminates this gap. Changes to your Laravel Data objects automatically propagate to TypeScript definitions. Your IDE immediately flags incompatibilities. Refactoring becomes safer because the type system spans both codebases.

The business impact is significant:

  • Reduced bugs: A 2025 academic study found that 94% of LLM-generated compilation errors were type-check failures, demonstrating how typed systems catch errors earlier in development
  • Faster development: Autocomplete and type hints eliminate guesswork about API response shapes
  • Safer refactoring: Rename a field in Laravel, and TypeScript immediately highlights every affected component
  • Better documentation: Types serve as living documentation that stays in sync with your code

Setting Up Vue 3 with TypeScript

Modern Vue 3 projects use Vite for fast development builds and native TypeScript support. If you are starting a new project, use the official scaffolding:

npm create vue@latest my-app

Select TypeScript when prompted. For existing projects, ensure your tsconfig.json includes Vue-specific settings:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Typing Vue Components with Composition API

Vue 3.3 and later versions provide excellent TypeScript support through the Composition API and <script setup> syntax. Here is a properly typed component:

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { User } from '@/types/models'
import { useUserApi } from '@/composables/useUserApi'

// Props with TypeScript
interface Props {
  userId: number
  showDetails?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  showDetails: false
})

// Emits with TypeScript
interface Emits {
  (e: 'update', user: User): void
  (e: 'delete', id: number): void
}

const emit = defineEmits<Emits>()

// Reactive state with type inference
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)

// Composable for API calls
const { fetchUser, updateUser } = useUserApi()

// Computed properties maintain type inference
const displayName = computed(() => {
  if (!user.value) return ''
  return `${user.value.firstName} ${user.value.lastName}`
})

// Async function with proper error handling
async function loadUser(): Promise<void> {
  loading.value = true
  error.value = null

  try {
    user.value = await fetchUser(props.userId)
  } catch (e) {
    error.value = e instanceof Error ? e.message : 'Failed to load user'
  } finally {
    loading.value = false
  }
}

async function handleUpdate(data: Partial<User>): Promise<void> {
  if (!user.value) return

  const updated = await updateUser(user.value.id, data)
  user.value = updated
  emit('update', updated)
}

onMounted(loadUser)
</script>

<template>
  <div class="user-card">
    <div v-if="loading">Loading...</div>
    <div v-else-if="error" class="error">{{ error }}</div>
    <div v-else-if="user">
      <h2>{{ displayName }}</h2>
      <p v-if="showDetails">{{ user.email }}</p>
      <button @click="handleUpdate({ status: 'active' })">
        Activate
      </button>
    </div>
  </div>
</template>

The lang="ts" attribute tells Vue to process the script block as TypeScript. Props, emits, and reactive references all benefit from type checking and IDE autocomplete.

Generating TypeScript Types from Laravel

The key to end-to-end type safety is automatically generating TypeScript definitions from your Laravel codebase. Two primary approaches exist: using Spatie's Laravel Data package or the new Laravel Wayfinder tool.

Option 1: Spatie Laravel Data with TypeScript Transformer

Laravel Data is a mature package that lets you define data structures once and use them for DTOs, form request validation, API resources, and TypeScript generation. Install the required packages:

composer require spatie/laravel-data
composer require spatie/laravel-typescript-transformer
php artisan vendor:publish --tag=typescript-transformer-config

Create a Data object that represents your API response:

<?php

namespace App\Data;

use Spatie\LaravelData\Data;
use Spatie\TypeScriptTransformer\Attributes\TypeScript;
use Carbon\Carbon;

#[TypeScript]
class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $firstName,
        public string $lastName,
        public string $email,
        public ?string $avatarUrl,
        public string $status,
        public Carbon $createdAt,
        public ?Carbon $emailVerifiedAt,
    ) {}

    public static function fromModel(User $user): self
    {
        return new self(
            id: $user->id,
            firstName: $user->first_name,
            lastName: $user->last_name,
            email: $user->email,
            avatarUrl: $user->avatar_url,
            status: $user->status,
            createdAt: $user->created_at,
            emailVerifiedAt: $user->email_verified_at,
        );
    }
}

Configure the TypeScript transformer in config/typescript-transformer.php:

<?php

return [
    'collectors' => [
        Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptCollector::class,
    ],

    'transformers' => [
        Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptTransformer::class,
        Spatie\TypeScriptTransformer\Transformers\EnumTransformer::class,
    ],

    'output_file' => resource_path('js/types/generated.d.ts'),

    'writer' => Spatie\TypeScriptTransformer\Writers\TypeDefinitionWriter::class,
];

Generate TypeScript definitions:

php artisan typescript:transform

This produces a generated.d.ts file:

// This file is auto-generated. Do not edit manually.

declare namespace App.Data {
  export interface UserData {
    id: number;
    firstName: string;
    lastName: string;
    email: string;
    avatarUrl: string | null;
    status: string;
    createdAt: string;
    emailVerifiedAt: string | null;
  }
}

Option 2: Laravel Wayfinder (Official Tool)

Laravel Wayfinder, announced at Laracon US 2025 and released in beta on April 2, 2025, provides deeper integration. It generates TypeScript functions for your routes and controllers, not just types:

composer require laravel/wayfinder
npm install @laravel/wayfinder

Wayfinder analyzes your Laravel application and generates TypeScript equivalents for routes, validation rules, models, enums, broadcast events, and environment variables. A controller like this:

<?php

namespace App\Http\Controllers\Api;

use App\Data\UserData;
use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;

class UserController extends Controller
{
    public function show(User $user): UserData
    {
        return UserData::from($user);
    }

    public function update(UpdateUserRequest $request, User $user): UserData
    {
        $user->update($request->validated());

        return UserData::from($user->fresh());
    }
}

Generates importable TypeScript functions:

import { users } from '@/wayfinder'

// Fully typed - IDE knows the return type is UserData
const user = await users.show({ user: 1 })

// TypeScript enforces the request body matches UpdateUserRequest rules
await users.update({ user: 1 }, {
  firstName: 'John',
  lastName: 'Doe'
})

Wayfinder also generates types from your Form Request validation rules. A Form Request like:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UpdateUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'firstName' => ['sometimes', 'string', 'max:255'],
            'lastName' => ['sometimes', 'string', 'max:255'],
            'email' => ['sometimes', 'email', 'unique:users,email,' . $this->user->id],
            'status' => ['sometimes', 'in:active,inactive,pending'],
        ];
    }
}

Produces TypeScript types that match your validation:

interface UpdateUserRequest {
  firstName?: string;
  lastName?: string;
  email?: string;
  status?: 'active' | 'inactive' | 'pending';
}

Building a Type-Safe API Client

With generated types in place, you need an API client that enforces them. Here is a robust pattern using Axios that provides type safety, error handling, and request configuration. For security considerations around API clients, see my guide on contact form security best practices:

// src/lib/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { ApiError, ValidationErrors } from '@/types/api'

// Custom error class for API errors
export class ApiRequestError extends Error {
  constructor(
    message: string,
    public status: number,
    public errors?: ValidationErrors
  ) {
    super(message)
    this.name = 'ApiRequestError'
  }
}

// Create configured Axios instance
function createApiClient(): AxiosInstance {
  const client = axios.create({
    baseURL: import.meta.env.VITE_API_URL || '/api',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    withCredentials: true, // Required for Sanctum cookie authentication
  })

  // Request interceptor for auth tokens
  client.interceptors.request.use((config) => {
    const token = localStorage.getItem('api_token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  })

  // Response interceptor for error handling
  client.interceptors.response.use(
    (response) => response,
    (error) => {
      if (axios.isAxiosError(error) && error.response) {
        const { status, data } = error.response

        // Handle validation errors (422)
        if (status === 422) {
          throw new ApiRequestError(
            data.message || 'Validation failed',
            status,
            data.errors
          )
        }

        // Handle authentication errors (401)
        if (status === 401) {
          // Redirect to login or refresh token
          window.location.href = '/login'
        }

        throw new ApiRequestError(
          data.message || 'An error occurred',
          status
        )
      }

      throw error
    }
  )

  return client
}

const apiClient = createApiClient()

// Type-safe request wrapper
export async function apiRequest<T>(
  config: AxiosRequestConfig
): Promise<T> {
  const response: AxiosResponse<T> = await apiClient.request(config)
  return response.data
}

// Convenience methods with full type inference
export const api = {
  get: <T>(url: string, config?: AxiosRequestConfig) =>
    apiRequest<T>({ ...config, method: 'GET', url }),

  post: <T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) =>
    apiRequest<T>({ ...config, method: 'POST', url, data }),

  put: <T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) =>
    apiRequest<T>({ ...config, method: 'PUT', url, data }),

  patch: <T, D = unknown>(url: string, data?: D, config?: AxiosRequestConfig) =>
    apiRequest<T>({ ...config, method: 'PATCH', url, data }),

  delete: <T>(url: string, config?: AxiosRequestConfig) =>
    apiRequest<T>({ ...config, method: 'DELETE', url }),
}

Creating Type-Safe Service Modules

Organize API calls into domain-specific modules that leverage your generated types:

// src/services/userService.ts
import { api } from '@/lib/api/client'
import type { UserData } from '@/types/generated'

// Request types (generated or manual)
interface CreateUserRequest {
  firstName: string
  lastName: string
  email: string
  password: string
}

interface UpdateUserRequest {
  firstName?: string
  lastName?: string
  email?: string
  status?: 'active' | 'inactive' | 'pending'
}

// Paginated response wrapper
interface PaginatedResponse<T> {
  data: T[]
  meta: {
    currentPage: number
    lastPage: number
    perPage: number
    total: number
  }
  links: {
    first: string
    last: string
    prev: string | null
    next: string | null
  }
}

interface UserFilters {
  status?: string
  search?: string
  page?: number
  perPage?: number
}

export const userService = {
  async list(filters?: UserFilters): Promise<PaginatedResponse<UserData>> {
    return api.get('/v1/users', { params: filters })
  },

  async get(id: number): Promise<UserData> {
    return api.get(`/v1/users/${id}`)
  },

  async create(data: CreateUserRequest): Promise<UserData> {
    return api.post('/v1/users', data)
  },

  async update(id: number, data: UpdateUserRequest): Promise<UserData> {
    return api.patch(`/v1/users/${id}`, data)
  },

  async delete(id: number): Promise<void> {
    return api.delete(`/v1/users/${id}`)
  },
}

Vue Composable for API State Management

Wrap your service in a composable that handles loading states, errors, and caching:

// src/composables/useUserApi.ts
import { ref, shallowRef } from 'vue'
import { userService } from '@/services/userService'
import { ApiRequestError } from '@/lib/api/client'
import type { UserData } from '@/types/generated'
import type { ValidationErrors } from '@/types/api'

export function useUserApi() {
  const user = shallowRef<UserData | null>(null)
  const users = shallowRef<UserData[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  const validationErrors = ref<ValidationErrors | null>(null)

  function clearErrors() {
    error.value = null
    validationErrors.value = null
  }

  async function fetchUser(id: number): Promise<UserData | null> {
    loading.value = true
    clearErrors()

    try {
      user.value = await userService.get(id)
      return user.value
    } catch (e) {
      if (e instanceof ApiRequestError) {
        error.value = e.message
        validationErrors.value = e.errors ?? null
      } else {
        error.value = 'An unexpected error occurred'
      }
      return null
    } finally {
      loading.value = false
    }
  }

  async function fetchUsers(filters?: Parameters<typeof userService.list>[0]) {
    loading.value = true
    clearErrors()

    try {
      const response = await userService.list(filters)
      users.value = response.data
      return response
    } catch (e) {
      if (e instanceof ApiRequestError) {
        error.value = e.message
      } else {
        error.value = 'An unexpected error occurred'
      }
      return null
    } finally {
      loading.value = false
    }
  }

  async function updateUser(
    id: number,
    data: Parameters<typeof userService.update>[1]
  ): Promise<UserData | null> {
    loading.value = true
    clearErrors()

    try {
      const updated = await userService.update(id, data)
      user.value = updated
      return updated
    } catch (e) {
      if (e instanceof ApiRequestError) {
        error.value = e.message
        validationErrors.value = e.errors ?? null
      } else {
        error.value = 'An unexpected error occurred'
      }
      return null
    } finally {
      loading.value = false
    }
  }

  return {
    // State
    user,
    users,
    loading,
    error,
    validationErrors,
    // Actions
    fetchUser,
    fetchUsers,
    updateUser,
    clearErrors,
  }
}

Handling Laravel Validation Errors with Proper Typing

Laravel returns validation errors in a specific format. Properly typing these errors enables intelligent form handling in Vue:

// src/types/api.ts

// Laravel's standard validation error response (422)
export interface ValidationErrorResponse {
  message: string
  errors: ValidationErrors
}

// Field-specific errors
export interface ValidationErrors {
  [field: string]: string[]
}

// Generic API error response
export interface ApiErrorResponse {
  error: {
    code: string
    message: string
    details?: ValidationErrors
  }
}

// Type guard for validation errors
export function isValidationError(error: unknown): error is ApiRequestError {
  return error instanceof ApiRequestError && error.status === 422
}

Form Component with Validation Error Display

Here is a complete form component that displays Laravel validation errors alongside form fields:

<script setup lang="ts">
import { reactive, computed } from 'vue'
import { useUserApi } from '@/composables/useUserApi'
import type { ValidationErrors } from '@/types/api'

interface FormData {
  firstName: string
  lastName: string
  email: string
  status: 'active' | 'inactive' | 'pending'
}

const props = defineProps<{
  userId: number
}>()

const emit = defineEmits<{
  (e: 'saved'): void
}>()

const { updateUser, loading, validationErrors, clearErrors } = useUserApi()

const form = reactive<FormData>({
  firstName: '',
  lastName: '',
  email: '',
  status: 'active',
})

// Helper to get field-specific errors
function getFieldErrors(field: keyof FormData): string[] {
  return validationErrors.value?.[field] ?? []
}

// Check if a field has errors
function hasError(field: keyof FormData): boolean {
  return getFieldErrors(field).length > 0
}

// Clear field error when user starts typing
function onInput(field: keyof FormData) {
  if (validationErrors.value?.[field]) {
    delete validationErrors.value[field]
  }
}

async function handleSubmit() {
  clearErrors()

  const result = await updateUser(props.userId, {
    firstName: form.firstName || undefined,
    lastName: form.lastName || undefined,
    email: form.email || undefined,
    status: form.status,
  })

  if (result) {
    emit('saved')
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="space-y-4">
    <div>
      <label for="firstName" class="block text-sm font-medium">
        First Name
      </label>
      <input
        id="firstName"
        v-model="form.firstName"
        type="text"
        :class="{ 'border-red-500': hasError('firstName') }"
        class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        @input="onInput('firstName')"
      />
      <p
        v-for="error in getFieldErrors('firstName')"
        :key="error"
        class="mt-1 text-sm text-red-600"
      >
        {{ error }}
      </p>
    </div>

    <div>
      <label for="lastName" class="block text-sm font-medium">
        Last Name
      </label>
      <input
        id="lastName"
        v-model="form.lastName"
        type="text"
        :class="{ 'border-red-500': hasError('lastName') }"
        class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        @input="onInput('lastName')"
      />
      <p
        v-for="error in getFieldErrors('lastName')"
        :key="error"
        class="mt-1 text-sm text-red-600"
      >
        {{ error }}
      </p>
    </div>

    <div>
      <label for="email" class="block text-sm font-medium">
        Email
      </label>
      <input
        id="email"
        v-model="form.email"
        type="email"
        :class="{ 'border-red-500': hasError('email') }"
        class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        @input="onInput('email')"
      />
      <p
        v-for="error in getFieldErrors('email')"
        :key="error"
        class="mt-1 text-sm text-red-600"
      >
        {{ error }}
      </p>
    </div>

    <div>
      <label for="status" class="block text-sm font-medium">
        Status
      </label>
      <select
        id="status"
        v-model="form.status"
        :class="{ 'border-red-500': hasError('status') }"
        class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
      >
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
        <option value="pending">Pending</option>
      </select>
      <p
        v-for="error in getFieldErrors('status')"
        :key="error"
        class="mt-1 text-sm text-red-600"
      >
        {{ error }}
      </p>
    </div>

    <button
      type="submit"
      :disabled="loading"
      class="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
    >
      {{ loading ? 'Saving...' : 'Save Changes' }}
    </button>
  </form>
</template>

Automating Type Generation in Development

Integrate type generation into your development workflow so types stay synchronized with your Laravel codebase.

NPM Scripts for Type Generation

Add scripts to your package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "npm run types && vue-tsc --noEmit && vite build",
    "types": "php artisan typescript:transform",
    "types:watch": "nodemon --watch 'app/Data/**/*.php' --ext php --exec 'npm run types'"
  }
}

Git Hooks with Husky

Ensure types are regenerated before commits:

npm install husky --save-dev
npx husky init

Create .husky/pre-commit:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# Regenerate TypeScript types
npm run types

# Add generated types to commit
git add resources/js/types/generated.d.ts

CI/CD Integration

Add type checking to your GitHub Actions workflow:

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install PHP dependencies
        run: composer install --no-progress

      - name: Install Node dependencies
        run: npm ci

      - name: Generate TypeScript types
        run: php artisan typescript:transform

      - name: Type check
        run: npx vue-tsc --noEmit

      - name: Build
        run: npm run build

Benefits of End-to-End Type Safety

After implementing this architecture across multiple Laravel and Vue projects, the benefits become clear:

Compile-time error detection: TypeScript catches mismatches between your API contract and frontend usage before deployment. If you rename a field from firstName to first_name in Laravel Data, your Vue components immediately show errors.

Improved developer experience: IDE autocomplete works across the entire stack. When you type user., your editor suggests firstName, lastName, email - the exact fields your Laravel API returns.

Safer refactoring: Changing API response shapes is no longer scary. TypeScript traces every usage and flags incompatibilities. You can refactor with confidence.

Living documentation: Generated types serve as documentation that never goes stale. New team members understand API shapes by reading type definitions.

Reduced testing burden: Type safety eliminates an entire category of bugs. While you still need integration tests, you no longer need tests that verify "does this field exist in the response."

Key Takeaways

Achieving end-to-end type safety between Laravel and Vue 3 requires investment in tooling and workflow, but the payoff is substantial. The key principles:

  • Generate types, do not write them manually: Use Laravel Data with TypeScript Transformer or Laravel Wayfinder to keep types synchronized automatically
  • Type your API client: Build a strongly-typed HTTP client that enforces request and response types
  • Handle validation errors properly: Laravel's 422 responses have a specific structure; type them correctly for intelligent form handling
  • Automate type generation: Integrate type generation into your development workflow and CI/CD pipeline
  • Use Vue composables for state management: Wrap API calls in composables that handle loading states and errors consistently

With TypeScript adoption now at 67% among professional developers and growing, these patterns position your codebase for long-term maintainability. The initial setup effort pays dividends every time you refactor, onboard a new developer, or catch a bug before it reaches production.


Building a full-stack application with Laravel and Vue? I specialize in Laravel API development and modern frontend architectures. My API Development service includes type-safe API design, frontend integration, and CI/CD setup. Schedule a free consultation to discuss your project requirements.


Related Reading:

External Resources:

Richard Joseph Porter - Professional headshot

Richard Joseph Porter

Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.

Need Help Upgrading Your Laravel App?

I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.