The Dev Log › Programming
TypeScript for Vue 3 Developers: The Parts You Actually Need
By Jezer Niel Blanca, Full Stack Developer ·
·
6 min read
A practical guide to TypeScript in Vue 3.5 with script setup: typed props, emits, defineModel, refs, template refs, generic composables and Inertia page props.
If you write Vue 3 but haven't made the jump to TypeScript yet, you're not alone. Plenty of developers I talk to assume it means verbose code, confusing generics and a slower workflow. With <script setup lang="ts">, it's none of those things. Most of the types are inferred for you, and the handful you write by hand pay for themselves the first time your editor catches a typo in a prop name. This guide covers the TypeScript you actually need as a Vue developer: typed props, emits and models, refs, composables, template refs, and typing your Inertia page props.
Why Bother With TypeScript in Vue?
The benefits show up in everyday work rather than in theory:
- Autocomplete everywhere. Your editor knows every prop, every field on a model and every event a component emits.
- Safer refactors. Rename a prop and every usage that breaks lights up immediately.
- Self-documenting components. A component's props type is its documentation.
- Fewer runtime surprises. Passing a string where a number was expected gets caught before the browser ever sees it.
The best part is that you can adopt it gradually. A .vue file with lang="ts" works alongside files without it, so you can convert one component at a time.
Typing Props the Simple Way
With <script setup>, you describe props with a plain TypeScript type passed to defineProps. No runtime props: { ... } object needed:
<script setup lang="ts">
type Project = {
id: number
name: string
status: 'draft' | 'active' | 'archived'
}
const { project, compact = false } = defineProps<{
project: Project
compact?: boolean
}>()
</script>
<template>
<article :class="compact ? 'p-3' : 'p-6'">
<h3>{{ project.name }}</h3>
<span>{{ project.status }}</span>
</article>
</template>
Since Vue 3.5, destructured props stay reactive, and you can set defaults with normal JavaScript default values as shown with compact. That replaces most uses of the older withDefaults helper.
Union Types Are Your Friend
Notice status: 'draft' | 'active' | 'archived'. A union of string literals is one of the most useful types in a Vue app. Pass 'actve' by mistake and TypeScript tells you straight away. I use them for statuses, variants like 'primary' | 'secondary', and sizes.
Typing Emits and v-model
Events deserve types just as much as props. The named tuple syntax is concise and readable:
<script setup lang="ts">
const emit = defineEmits<{
save: [id: number]
cancel: []
updateStatus: [id: number, status: 'draft' | 'active' | 'archived']
}>()
function handleSave(id: number) {
emit('save', id)
}
</script>
Now emit('save') without an ID is a type error, and parent components get autocomplete for @save and @update-status.
defineModel for Two-Way Binding
For inputs and toggles, defineModel removes the boilerplate of a prop plus an update event:
<script setup lang="ts">
const search = defineModel<string>({ default: '' })
const isOpen = defineModel<boolean>('open', { default: false })
</script>
<template>
<input v-model="search" type="search" placeholder="Search projects">
<button type="button" @click="isOpen = !isOpen">Toggle filters</button>
</template>
The parent uses v-model="query" and v-model:open="filtersOpen", and both are fully typed.
Refs, Computed and When to Annotate
TypeScript infers most reactive values for you, so don't over-annotate:
import { computed, ref } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
Both are inferred correctly: count is Ref<number> and doubled is a computed number. You only need an explicit type when the initial value doesn't tell the whole story:
type User = { id: number; name: string; email: string }
const selectedUser = ref<User | null>(null)
const errors = ref<Record<string, string>>({})
Without the annotation, ref(null) would only ever accept null. That's the single most common place newcomers get stuck.
Let TypeScript infer what it can, and annotate only where the starting value is empty, null or incomplete. You'll write far fewer types than you expect.
Typing Template Refs
Vue 3.5 added useTemplateRef, which makes element and component refs clearer:
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const searchInput = useTemplateRef<HTMLInputElement>('search')
onMounted(() => {
searchInput.value?.focus()
})
</script>
<template>
<input ref="search" type="search">
</template>
The optional chaining matters because the element doesn't exist until the component is mounted.
Typed Composables
Composables are where TypeScript really earns its place, because they're reused across many components. Give the function typed parameters and let the return type be inferred:
import { computed, ref, type Ref } from 'vue'
export function usePagination<T>(items: Ref<T[]>, perPage = 10) {
const page = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / perPage)))
const visibleItems = computed(() =>
items.value.slice((page.value - 1) * perPage, page.value * perPage),
)
function next() {
page.value = Math.min(page.value + 1, totalPages.value)
}
function previous() {
page.value = Math.max(page.value - 1, 1)
}
return { page, totalPages, visibleItems, next, previous }
}
The generic T means visibleItems keeps the exact item type you passed in. Call it with a list of projects and you get projects back, with full autocomplete.
Generic Components
The same idea works for components. A reusable list or table can accept any item type:
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{
items: T[]
}>()
defineEmits<{
select: [item: T]
}>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id" @click="$emit('select', item)">
<slot :item="item" />
</li>
</ul>
</template>
Typing Inertia Page Props
In an Inertia app, your page components receive props from Laravel. I keep shared types in a types folder that mirrors the data the controllers send:
export type SharedProps = {
auth: { user: { id: number; name: string } | null }
flash: { success?: string; error?: string }
}
Then use them with Inertia's usePage:
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
import type { SharedProps } from '@/types'
const page = usePage<SharedProps>()
const userName = computed(() => page.props.auth.user?.name ?? 'Guest')
Forms benefit too. useForm({ name: '', budget: 0 }) infers its fields, so form.errors.name and form.budget are typed automatically.
Keep Types Honest
Types describe what you expect the server to send, not what it guarantees. Keep them close to your API resources, update them in the same pull request as backend changes, and run a type check in CI with vue-tsc --noEmit so drift gets caught early.
Wrapping up
TypeScript in Vue 3 is mostly inference plus a few well-placed annotations: typed defineProps, tuple-style defineEmits, defineModel, explicit types for empty refs, generic composables and shared Inertia prop types. Convert one component this week and you'll feel the difference in your editor immediately. If you want a Vue and Laravel product built with this level of care, I'd love to build it with you and my team.
Tags: Vue, TypeScript, Inertia, Frontend