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.
Inertia 2.0 brought us some brilliant new features:
But here's the problem: these features all involve data that loads at different times. Without types, you're constantly guessing:
With TypeScript, your IDE just tells you. No guessing, no bugs from typos, no runtime errors from wrong data shapes.
Let's start simple. In PHP, you'd write:
1class User 2{ 3 public function __construct( 4 public int $id, 5 public string $name, 6 public string $email, 7 public ?string $avatar, 8 ) {} 9}10 11public function show(User $user): Response12{13 return Inertia::render('Users/Show', [14 'user' => $user15 ]);16}
The TypeScript equivalent is almost identical:
1interface User { 2 id: number 3 name: string 4 email: string 5 avatar: string | null 6} 7 8interface Props { 9 user: User10}11 12export default function Show({ user }: Props) {13 // TypeScript knows exactly what `user` contains14 return (15 <div>16 <h1>{user.name}</h1>17 <img src={user.avatar ?? '/default.jpg'} />18 </div>19 )20}
See? Same idea. Just different punctuation.
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:
1laravel new my-app2# Select "React" when prompted
You get:
The starter kit even includes a tsconfig.json that just works:
1{ 2 "compilerOptions": { 3 "target": "ES2020", 4 "useDefineForClassFields": true, 5 "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 "module": "ESNext", 7 "skipLibCheck": true, 8 "moduleResolution": "bundler", 9 "allowImportingTsExtensions": true,10 "resolveJsonModule": true,11 "isolatedModules": true,12 "noEmit": true,13 "jsx": "react-jsx",14 "strict": true,15 "noUnusedLocals": true,16 "noUnusedParameters": true,17 "noFallthroughCasesInSwitch": true,18 "paths": {19 "@/*": ["./resources/js/*"]20 }21 },22 "include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"],23 "references": [{ "path": "./tsconfig.node.json" }]24}
Don't worry about understanding all of this. The important bits:
"strict": true - catches bugs early"paths" - lets you use @/components instead of ../../../components"jsx": "react-jsx" - makes React work with TypeScriptLet's look at how TypeScript makes Inertia 2.0's new features actually useful.
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.
1import { Link } from '@inertiajs/react' 2 3interface User { 4 id: number 5 name: string 6 email: string 7} 8 9export default function UsersList({ users }: { users: User[] }) {10 return (11 <div>12 {users.map(user => (13 <Link14 key={user.id}15 href={`/users/${user.id}`}16 prefetch17 >18 {user.name}19 </Link>20 ))}21 </div>22 )23}
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 let you load slow data in the background. But they might not be available immediately, so you need to handle that.
1interface DashboardProps { 2 stats: { 3 users: number 4 revenue: number 5 } 6 // Analytics loads after the page renders 7 analytics?: { 8 views: number[] 9 conversions: number[]10 }11}12 13export default function Dashboard({ stats, analytics }: DashboardProps) {14 return (15 <div>16 {/* Stats always available */}17 <QuickStats data={stats} />18 19 {/* Analytics might not be loaded yet */}20 {analytics ? (21 <AnalyticsChart data={analytics} />22 ) : (23 <div>Loading analytics...</div>24 )}25 </div>26 )27}
See the ? in analytics?:? That tells TypeScript this prop is optional. Now if you forget to check if it exists, TypeScript will complain:
1// TypeScript error: Object is possibly 'undefined'2<AnalyticsChart data={analytics} />3 4// This works fine5{analytics && <AnalyticsChart data={analytics} />}
On the Laravel side:
1return Inertia::render('Dashboard', [2 'stats' => $quickStats,3 'analytics' => Inertia::defer(fn() => $expensiveAnalyticsQuery)4]);
Forms in Inertia are brilliant, but without types they're a pain. With TypeScript, you get autocomplete for everything:
1import { useForm } from '@inertiajs/react' 2 3interface UserFormData { 4 name: string 5 email: string 6 avatar: File | null 7} 8 9export default function CreateUser() {10 const { data, setData, post, processing, errors } = useForm<UserFormData>({11 name: '',12 email: '',13 avatar: null14 })15 16 function submit(e: React.FormEvent) {17 e.preventDefault()18 post('/users')19 }20 21 return (22 <form onSubmit={submit}>23 <input24 type="text"25 value={data.name}26 onChange={e => setData('name', e.target.value)}27 />28 {errors.name && <div>{errors.name}</div>}29 30 <input31 type="email"32 value={data.email}33 onChange={e => setData('email', e.target.value)}34 />35 {errors.email && <div>{errors.email}</div>}36 37 <input38 type="file"39 onChange={e => setData('avatar', e.target.files?.[0] ?? null)}40 />41 42 <button type="submit" disabled={processing}>43 {processing ? 'Creating...' : 'Create User'}44 </button>45 </form>46 )47}
TypeScript knows:
data.name is a stringsetData only accepts valid field nameserrors can have name, email, or avatar propertiesprocessing is a booleanTry to use setData('wrong_field', 'value') and TypeScript will stop you before you even run the code.
Inertia 2.0's router methods are now async by default. TypeScript helps you handle this properly:
1import { router } from '@inertiajs/react' 2 3async function refreshComments() { 4 // Non-blocking reload 5 router.reload({ 6 only: ['comments'], 7 preserveScroll: true 8 }) 9}10 11function prefetchNextPage(page: number) {12 router.prefetch(`/posts?page=${page}`)13}
Typing your props manually is fine for small apps. But for real projects, you want types generated automatically from your Laravel code.
Install the packages:
1composer require spatie/laravel-data2composer require spatie/laravel-typescript-transformer --dev3npm install -D @types/node
Create a Data object in Laravel:
1use Spatie\LaravelData\Data; 2 3class UserData extends Data 4{ 5 public function __construct( 6 public int $id, 7 public string $name, 8 public string $email, 9 public ?string $avatar,10 public CarbonImmutable $created_at,11 ) {}12}
Configure the transformer in config/typescript-transformer.php:
1'collectors' => [2 Spatie\TypeScriptTransformer\Collectors\DefaultCollector::class,3],4 5'output_file' => resource_path('js/types/generated.d.ts'),
Generate types:
1php artisan typescript:transform
This creates resources/js/types/generated.d.ts:
1export interface UserData {2 id: number3 name: string4 email: string5 avatar: string | null6 created_at: string7}
Now use it in your components:
1import { UserData } from '@/types/generated' 2 3interface Props { 4 user: UserData 5} 6 7export default function UserProfile({ user }: Props) { 8 // Full type safety from PHP to React 9 return <div>{user.name}</div>10}
Ziggy gives you Laravel routes in JavaScript. Install it:
1composer require tightenco/ziggy2npm install ziggy-js
Generate routes:
1php artisan ziggy:generate
Use in TypeScript:
1import { route } from 'ziggy-js'2 3// TypeScript knows all your routes4<Link href={route('users.show', user.id)}>5 View User6</Link>7 8// Autocomplete for route names9router.visit(route('dashboard'))
Generics are the only part of TypeScript that doesn't exist in PHP. But they're dead useful for pagination.
1interface Paginator<T> { 2 data: T[] 3 current_page: number 4 last_page: number 5 per_page: number 6 total: number 7} 8 9interface Props {10 users: Paginator<UserData>11 posts: Paginator<PostData>12}
Now users.data is typed as UserData[] and posts.data is PostData[]. The Paginator type works for any data type you pass in.
1interface Props { 2 user: UserData 3 showAvatar?: boolean // Optional, defaults to undefined 4} 5 6export default function UserCard({ user, showAvatar = true }: Props) { 7 return ( 8 <div> 9 <h2>{user.name}</h2>10 {showAvatar && user.avatar && (11 <img src={user.avatar} alt={user.name} />12 )}13 </div>14 )15}
1type LoadingState = 2 | { status: 'idle' } 3 | { status: 'loading' } 4 | { status: 'success', data: UserData[] } 5 | { status: 'error', message: string } 6 7function UsersList({ state }: { state: LoadingState }) { 8 switch (state.status) { 9 case 'loading':10 return <div>Loading...</div>11 case 'success':12 return <div>{state.data.length} users</div> // TypeScript knows data exists13 case 'error':14 return <div>Error: {state.message}</div> // TypeScript knows message exists15 default:16 return null17 }18}
1interface ButtonProps { 2 onClick: (e: React.MouseEvent) => void 3 children: React.ReactNode 4 disabled?: boolean 5} 6 7export function Button({ onClick, children, disabled = false }: ButtonProps) { 8 return ( 9 <button onClick={onClick} disabled={disabled}>10 {children}11 </button>12 )13}
You don't need to type everything at once. TypeScript works fine alongside JavaScript. Here's how to start:
.jsx to .tsxExample of what NOT to do:
1// Too much typing (TypeScript can infer this)2const [count, setCount] = useState<number>(0)3const [name, setName] = useState<string>('')4 5// Better (TypeScript already knows)6const [count, setCount] = useState(0) // Inferred as number7const [name, setName] = useState('') // Inferred as string
Only type things when TypeScript can't figure it out:
1// TypeScript can't infer this, so we help it2const [user, setUser] = useState<UserData | null>(null)3 4// This is needed because the array starts empty5const [users, setUsers] = useState<UserData[]>([])
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:
1// Error: Property 'analytics' does not exist2const total = props.analytics.views.length3 4// Fix: Check it exists first5const total = props.analytics?.views.length ?? 0
"Argument of type 'X' is not assignable to parameter of type 'Y'"
You're passing the wrong type:
1// Error: Expected number, got string2setData('age', '25')3 4// Fix: Convert to number5setData('age', parseInt('25'))
"Object is possibly 'null'"
TypeScript thinks something might be null:
1// Error2const file = e.target.files[0]3 4// Fix: Check it exists5const file = e.target.files?.[0] ?? null
Here's a complete example showing how TypeScript helps with Inertia 2.0's features:
1import { useForm } from '@inertiajs/react' 2import { UserData } from '@/types/generated' 3 4interface DashboardProps { 5 user: UserData 6 stats: { 7 revenue: number 8 customers: number 9 }10 // Deferred prop - loads in background11 recentActivity?: Array<{12 id: number13 type: 'sale' | 'signup' | 'cancellation'14 created_at: string15 }>16}17 18export default function Dashboard({ user, stats, recentActivity }: DashboardProps) {19 const form = useForm({20 search: ''21 })22 23 function handleSearch(e: React.FormEvent) {24 e.preventDefault()25 form.get('/search', {26 preserveState: true,27 preserveScroll: true,28 only: ['results']29 })30 }31 32 return (33 <div>34 <h1>Welcome back, {user.name}</h1>35 36 <div className="grid grid-cols-2 gap-4">37 <StatCard38 label="Revenue"39 value={`£${stats.revenue.toLocaleString()}`}40 />41 <StatCard42 label="Customers"43 value={stats.customers}44 />45 </div>46 47 {recentActivity ? (48 <ActivityFeed activities={recentActivity} />49 ) : (50 <div>Loading recent activity...</div>51 )}52 53 <form onSubmit={handleSearch}>54 <input55 type="text"56 value={form.data.search}57 onChange={e => form.setData('search', e.target.value)}58 placeholder="Search..."59 />60 <button type="submit" disabled={form.processing}>61 Search62 </button>63 </form>64 </div>65 )66}67 68interface StatCardProps {69 label: string70 value: string | number71}72 73function StatCard({ label, value }: StatCardProps) {74 return (75 <div className="bg-white p-4 rounded">76 <div className="text-sm text-grey-600">{label}</div>77 <div className="text-2xl font-bold">{value}</div>78 </div>79 )80}
Install these:
Add to your eslint.config.js:
1import typescript from '@typescript-eslint/eslint-plugin' 2import tsParser from '@typescript-eslint/parser' 3 4export default [ 5 { 6 files: ['**/*.ts', '**/*.tsx'], 7 languageOptions: { 8 parser: tsParser, 9 parserOptions: {10 project: './tsconfig.json'11 }12 },13 plugins: {14 '@typescript-eslint': typescript15 },16 rules: {17 '@typescript-eslint/no-unused-vars': 'error',18 '@typescript-eslint/no-explicit-any': 'warn'19 }20 }21]
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.
Written by
Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.
Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.