Look, I'll be honest with you TypeScript used to scare me. Actually, it still does sometimes.
I'm a Laravel developer. I love PHP 8.3 type system. I use typed properties, return types, and union types every day. But for the longest time, I avoided TypeScript in my frontend code like it was some sort of complicated monster.
The thing is, I was making it harder than it needed to be. Once I realised that TypeScript for React is basically the same skill as typing PHP code (just different syntax), everything clicked into place.
If you're building with Inertia 2.0 and React, TypeScript isn't just nice to have it makes the new async features actually usable in production. Let me show you why.
Why TypeScript Matters with Inertia 2.0
Inertia 2.0 brought us some brilliant new features:
- Async requests that don't block each other
- Prefetching for instant page loads
- Deferred props for loading heavy data in the background
- Polling for real-time updates
But here's the problem: these features all involve data that loads at different times. Without types, you're constantly guessing:
- Is this prop loaded yet?
- What shape does this data have?
- Did the API change and I missed it?
With TypeScript, your IDE just tells you. No guessing, no bugs from typos, no runtime errors from wrong data shapes.
The Basics (if you know PHP types, you already know this)
Let's start simple. In PHP, you'd write:
class User
{
public function __construct(
public int $id,
public string $name,
public string $email,
public ?string $avatar,
) {}
}
public function show(User $user): Response
{
return Inertia::render('Users/Show', [
'user' => $user
]);
}
The TypeScript equivalent is almost identical:
interface User {
id: number
name: string
email: string
avatar: string | null
}
interface Props {
user: User
}
export default function Show({ user }: Props) {
// TypeScript knows exactly what `user` contains
return (
<div>
<h1>{user.name}</h1>
<img src={user.avatar ?? '/default.jpg'} />
</div>
)
}
See? Same idea. Just different punctuation.
Setting up TypeScript with the new Laravel starter kit
Good news: if you're using Laravel's new React starter kit (the one with shadcn/ui), TypeScript is already set up for you. Just run:
laravel new my-app
# Select "React" when prompted
You get:
- TypeScript configured
- React 19
- Inertia 2.0
- All the types you need
The starter kit even includes a tsconfig.json that just works:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./resources/js/*"]
}
},
"include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Don't worry about understanding all of this. The important bits:
"strict": true- catches bugs early"paths"- lets you use@/componentsinstead of../../../components"jsx": "react-jsx"- makes React work with TypeScript
Real Inertia 2.0 Examples
Let's look at how TypeScript makes Inertia 2.0's new features actually useful.
Prefetching with known data shapes
Inertia 2.0 can prefetch pages before users click on them. By default, it prefetches when you hover over a link for more than 75ms.
import { Link } from '@inertiajs/react'
interface User {
id: number
name: string
email: string
}
export default function UsersList({ users }: { users: User[] }) {
return (
<div>
{users.map(user => (
<Link
key={user.id}
href={`/users/${user.id}`}
prefetch
>
{user.name}
</Link>
))}
</div>
)
}
Without types, you'd have to remember what data /users/{id} returns. With types, you just write your component and TypeScript tells you if you get it wrong.
Deferred Props (this is where TypeScript shines)
Deferred props let you load slow data in the background. But they might not be available immediately, so you need to handle that.
interface DashboardProps {
stats: {
users: number
revenue: number
}
// Analytics loads after the page renders
analytics?: {
views: number[]
conversions: number[]
}
}
export default function Dashboard({ stats, analytics }: DashboardProps) {
return (
<div>
{/* Stats always available */}
<QuickStats data={stats} />
{/* Analytics might not be loaded yet */}
{analytics ? (
<AnalyticsChart data={analytics} />
) : (
<div>Loading analytics...</div>
)}
</div>
)
}
See the ? in analytics?:? That tells TypeScript this prop is optional. Now if you forget to check if it exists, TypeScript will complain:
// TypeScript error: Object is possibly 'undefined'
<AnalyticsChart data={analytics} />
// This works fine
{analytics && <AnalyticsChart data={analytics} />}
On the Laravel side:
return Inertia::render('Dashboard', [
'stats' => $quickStats,
'analytics' => Inertia::defer(fn() => $expensiveAnalyticsQuery)
]);
The useForm Hook (Type-Safe Forms)
Forms in Inertia are brilliant, but without types they're a pain. With TypeScript, you get autocomplete for everything:
import { useForm } from '@inertiajs/react'
interface UserFormData {
name: string
email: string
avatar: File | null
}
export default function CreateUser() {
const { data, setData, post, processing, errors } = useForm<UserFormData>({
name: '',
email: '',
avatar: null
})
function submit(e: React.FormEvent) {
e.preventDefault()
post('/users')
}
return (
<form onSubmit={submit}>
<input
type="text"
value={data.name}
onChange={e => setData('name', e.target.value)}
/>
{errors.name && <div>{errors.name}</div>}
<input
type="email"
value={data.email}
onChange={e => setData('email', e.target.value)}
/>
{errors.email && <div>{errors.email}</div>}
<input
type="file"
onChange={e => setData('avatar', e.target.files?.[0] ?? null)}
/>
<button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create User'}
</button>
</form>
)
}
TypeScript knows:
data.nameis a stringsetDataonly accepts valid field nameserrorscan havename,email, oravatarpropertiesprocessingis a boolean
Try to use setData('wrong_field', 'value') and TypeScript will stop you before you even run the code.
Async router calls
Inertia 2.0's router methods are now async by default. TypeScript helps you handle this properly:
import { router } from '@inertiajs/react'
async function refreshComments() {
// Non-blocking reload
router.reload({
only: ['comments'],
preserveScroll: true
})
}
function prefetchNextPage(page: number) {
router.prefetch(`/posts?page=${page}`)
}
Generating types from Laravel
Typing your props manually is fine for small apps. But for real projects, you want types generated automatically from your Laravel code.
Option 1: Laravel Data + TypeScript Transformer
Install the packages:
composer require spatie/laravel-data
composer require spatie/laravel-typescript-transformer --dev
npm install -D @types/node
Create a Data object in Laravel:
use Spatie\LaravelData\Data;
class UserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public ?string $avatar,
public CarbonImmutable $created_at,
) {}
}
Configure the transformer in config/typescript-transformer.php:
'collectors' => [
Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,
],
'output_file' => resource_path('js/types/generated.d.ts'),
Generate types:
php artisan typescript:transform
This creates resources/js/types/generated.d.ts:
export interface UserData {
id: number
name: string
email: string
avatar: string | null
created_at: string
}
Now use it in your components:
import { UserData } from '@/types/generated'
interface Props {
user: UserData
}
export default function UserProfile({ user }: Props) {
// Full type safety from PHP to React
return <div>{user.name}</div>
}
Option 2: Ziggy for Type-Safe Routes
Ziggy gives you Laravel routes in JavaScript. Install it:
composer require tightenco/ziggy
npm install ziggy-js
Generate routes:
php artisan ziggy:generate
Use in TypeScript:
import { route } from 'ziggy-js'
// TypeScript knows all your routes
<Link href={route('users.show', user.id)}>
View User
</Link>
// Autocomplete for route names
router.visit(route('dashboard'))
Generics (the one thing PHP doesn't have)
Generics are the only part of TypeScript that doesn't exist in PHP. But they're dead useful for pagination.
interface Paginator<T> {
data: T[]
current_page: number
last_page: number
per_page: number
total: number
}
interface Props {
users: Paginator<UserData>
posts: Paginator<PostData>
}
Now users.data is typed as UserData[] and posts.data is PostData[]. The Paginator type works for any data type you pass in.
Common Patterns
Optional props with defaults
interface Props {
user: UserData
showAvatar?: boolean // Optional, defaults to undefined
}
export default function UserCard({ user, showAvatar = true }: Props) {
return (
<div>
<h2>{user.name}</h2>
{showAvatar && user.avatar && (
<img src={user.avatar} alt={user.name} />
)}
</div>
)
}
Union types for different states
type LoadingState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success', data: UserData[] }
| { status: 'error', message: string }
function UsersList({ state }: { state: LoadingState }) {
switch (state.status) {
case 'loading':
return <div>Loading...</div>
case 'success':
return <div>{state.data.length} users</div> // TypeScript knows data exists
case 'error':
return <div>Error: {state.message}</div> // TypeScript knows message exists
default:
return null
}
}
Typing children and events
interface ButtonProps {
onClick: (e: React.MouseEvent) => void
children: React.ReactNode
disabled?: boolean
}
export function Button({ onClick, children, disabled = false }: ButtonProps) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
)
}
Gradual Adoption
You don't need to type everything at once. TypeScript works fine alongside JavaScript. Here's how to start:
- Rename one file from
.jsxto.tsx - Add prop types to that component
- Let TypeScript infer the rest (don't add types everywhere)
- Repeat with more components as you touch them
Example of what NOT to do:
// Too much typing (TypeScript can infer this)
const [count, setCount] = useState<number>(0)
const [name, setName] = useState<string>('')
// Better (TypeScript already knows)
const [count, setCount] = useState(0) // Inferred as number
const [name, setName] = useState('') // Inferred as string
Only type things when TypeScript can't figure it out:
// TypeScript can't infer this, so we help it
const [user, setUser] = useState<UserData | null>(null)
// This is needed because the array starts empty
const [users, setUsers] = useState<UserData[]>([])
Handling Errors
TypeScript will complain sometimes. Here's how to fix the common ones:
"Property 'X' does not exist"
This usually means you're accessing a property that might not exist:
// Error: Property 'analytics' does not exist
const total = props.analytics.views.length
// Fix: Check it exists first
const total = props.analytics?.views.length ?? 0
"Argument of type 'X' is not assignable to parameter of type 'Y'"
You're passing the wrong type:
// Error: Expected number, got string
setData('age', '25')
// Fix: Convert to number
setData('age', parseInt('25'))
"Object is possibly 'null'"
TypeScript thinks something might be null:
// Error
const file = e.target.files[0]
// Fix: Check it exists
const file = e.target.files?.[0] ?? null
Real Production Example
Here's a complete example showing how TypeScript helps with Inertia 2.0's features:
import { useForm } from '@inertiajs/react'
import { UserData } from '@/types/generated'
interface DashboardProps {
user: UserData
stats: {
revenue: number
customers: number
}
// Deferred prop - loads in background
recentActivity?: Array<{
id: number
type: 'sale' | 'signup' | 'cancellation'
created_at: string
}>
}
export default function Dashboard({ user, stats, recentActivity }: DashboardProps) {
const form = useForm({
search: ''
})
function handleSearch(e: React.FormEvent) {
e.preventDefault()
form.get('/search', {
preserveState: true,
preserveScroll: true,
only: ['results']
})
}
return (
<div>
<h1>Welcome back, {user.name}</h1>
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Revenue"
value={`£${stats.revenue.toLocaleString()}`}
/>
<StatCard
label="Customers"
value={stats.customers}
/>
</div>
{recentActivity ? (
<ActivityFeed activities={recentActivity} />
) : (
<div>Loading recent activity...</div>
)}
<form onSubmit={handleSearch}>
<input
type="text"
value={form.data.search}
onChange={e => form.setData('search', e.target.value)}
placeholder="Search..."
/>
<button type="submit" disabled={form.processing}>
Search
</button>
</form>
</div>
)
}
interface StatCardProps {
label: string
value: string | number
}
function StatCard({ label, value }: StatCardProps) {
return (
<div className="bg-white p-4 rounded">
<div className="text-sm text-grey-600">{label}</div>
<div className="text-2xl font-bold">{value}</div>
</div>
)
}
Tools That Make It Better
VS Code Extensions
Install these:
- TypeScript Vue Plugin (even for React, helps with types)
- Error Lens - shows TypeScript errors inline
- Pretty TypeScript Errors - makes errors actually readable
ESLint + TypeScript
Add to your eslint.config.js:
import typescript from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
export default [
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json'
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn'
}
}
]
The Bottom Line
TypeScript with Inertia 2.0 isn't about writing more code. It's about writing code once and having it work.
Inertia 2.0's async features (prefetching, deferred props, polling) all involve data loading at different times. Without types, you're constantly checking "is this loaded yet?" and "what shape is this data?"
With TypeScript, your IDE tells you. Your code either compiles or it doesn't. No runtime surprises.
And the best part? If you already write typed PHP in Laravel, you already know how to write TypeScript. It's the same skill, just different brackets.
Start with one component. Add types to the props. See how much easier it makes your life. Then do another one. Before you know it, you'll wonder how you ever built Inertia apps without it.