Vue.js 3: Zero to Senior · Lesson 6 of 7
TypeScript with Vue 3
TypeScript Config for Vue 3
JSON
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
"vueCompilerOptions": {
"strictTemplates": true // type-check template expressions
}
}"strictTemplates": true tells vue-tsc to type-check your <template> — catches things like passing the wrong prop type.
Typed Domain Models
Define your types in a central location:
TYPESCRIPT
// src/types/index.ts
export type AppointmentStatus = 'scheduled' | 'confirmed' | 'completed' | 'cancelled' | 'no_show'
export type AppointmentType = 'routine' | 'follow_up' | 'emergency' | 'contact_lens'
export interface Appointment {
id: string
clinicId: string
clinicName: string
patientId: string
patientName: string
dateTime: string // ISO string from API
status: AppointmentStatus
type: AppointmentType
notes: string | null
createdAt: string
}
export interface Patient {
id: string
firstName: string
lastName: string
fullName: string
email: string
phone: string
dateOfBirth: string
clinicId: string
}
export interface Clinic {
id: string
name: string
state: string
phone: string
active: boolean
}
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
hasMore: boolean
}
export interface ApiError {
message: string
code?: string
errors?: { field: string; message: string }[]
}Typed Component Props & Emits
VUE
<script setup lang="ts">
import type { Appointment, AppointmentStatus } from '@/types'
// Props — TypeScript interface
interface Props {
appointment: Appointment
compact?: boolean
selectable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
compact: false,
selectable: false,
})
// Emits — typed tuple syntax
interface Emits {
select: [id: string]
cancel: [id: string, reason: string]
reschedule: [id: string, newDate: Date]
statusChange: [id: string, status: AppointmentStatus]
}
const emit = defineEmits<Emits>()
// Typed computed
const statusLabel = computed((): string => {
const labels: Record<AppointmentStatus, string> = {
scheduled: 'Upcoming',
confirmed: 'Confirmed',
completed: 'Done',
cancelled: 'Cancelled',
no_show: 'No Show',
}
return labels[props.appointment.status]
})
// Typed template ref
const cardRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
if (props.selectable) {
cardRef.value?.focus()
}
})
</script>
<template>
<div ref="cardRef" tabindex="0" @click="emit('select', appointment.id)">
<span>{{ statusLabel }}</span>
</div>
</template>Generic Components
Write components that work with any data type:
VUE
<!-- src/components/DataTable.vue -->
<script setup lang="ts" generic="T extends { id: string }">
interface Column<T> {
key: keyof T
label: string
render?: (value: T[keyof T], row: T) => string
}
interface Props {
rows: T[]
columns: Column<T>[]
loading?: boolean
onRowClick?: (row: T) => void
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
})
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="String(col.key)">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="row.id"
:class="{ 'cursor-pointer hover:bg-muted/50': !!onRowClick }"
@click="onRowClick?.(row)"
>
<td v-for="col in columns" :key="String(col.key)">
{{ col.render ? col.render(row[col.key], row) : row[col.key] }}
</td>
</tr>
</tbody>
</table>
</template>Usage:
VUE
<DataTable
:rows="appointments"
:columns="[
{ key: 'patientName', label: 'Patient' },
{ key: 'dateTime', label: 'Date',
render: (v) => new Date(v as string).toLocaleString() },
{ key: 'status', label: 'Status' },
]"
:on-row-click="(appt) => router.push({ name: 'AppointmentDetail', params: { id: appt.id } })"
/>Typed API Client
TYPESCRIPT
// src/lib/api.ts
import { get, post, put } from 'aws-amplify/api'
import type { Appointment, AppointmentRequest, Patient, PaginatedResponse, ApiError } from '@/types'
class ApiClient {
private async request<T>(
method: 'get' | 'post' | 'put' | 'delete',
path: string,
options: { body?: unknown; queryParams?: Record<string, string> } = {}
): Promise<T> {
// ...implementation
}
// Fully typed API methods
async getAppointments(params: {
clinicId: string
date?: string
status?: string
page?: number
size?: number
}): Promise<PaginatedResponse<Appointment>> {
return this.request('get', '/appointments', { queryParams: params as any })
}
async createAppointment(data: AppointmentRequest): Promise<Appointment> {
return this.request('post', '/appointments', { body: data })
}
async updateStatus(id: string, status: string): Promise<Appointment> {
return this.request('put', `/appointments/${id}/status`, { body: { status } })
}
async searchPatients(query: string): Promise<Patient[]> {
return this.request('get', '/patients/search', { queryParams: { q: query } })
}
}
export const api = new ApiClient()Typed Route Meta
TYPESCRIPT
// src/router/types.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth: boolean
requiresAdmin?: boolean
title?: string
breadcrumb?: string[]
}
}Now route.meta.requiresAuth is typed as boolean, not unknown.
Handling API Errors with Type Guards
TYPESCRIPT
// src/lib/errors.ts
import type { ApiError } from '@/types'
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as any).message === 'string'
)
}
export function getErrorMessage(error: unknown): string {
if (isApiError(error)) return error.message
if (error instanceof Error) return error.message
return 'An unexpected error occurred'
}
// Usage in a store
async function create(request: AppointmentRequest) {
try {
return await api.createAppointment(request)
} catch (e: unknown) {
error.value = getErrorMessage(e)
throw e
}
}Utility Types
TYPESCRIPT
// src/types/utils.ts
// Make all properties optional for partial updates
type PartialUpdate<T> = Partial<Omit<T, 'id' | 'createdAt'>>
// Extract request type from entity
type CreateRequest<T> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>
// Readonly state (for store exports)
type ReadonlyState<T> = Readonly<T>
// Form values — convert all to string for HTML inputs
type FormValues<T> = {
[K in keyof T]: T[K] extends string | number | boolean | null
? string
: never
}
// Loading state wrapper
interface AsyncState<T> {
data: T | null
loading: boolean
error: string | null
}
// Usage
const appointmentState: AsyncState<Appointment[]> = {
data: null,
loading: false,
error: null,
}Component Ref Typing
VUE
<script setup lang="ts">
import { ref } from 'vue'
import MyModal from '@/components/MyModal.vue'
// Type the ref to the component instance
const modalRef = ref<InstanceType<typeof MyModal> | null>(null)
function openModal() {
modalRef.value?.open() // calls exposed method on child component
}
</script>
<template>
<MyModal ref="modalRef" />
</template>VUE
<!-- MyModal.vue -->
<script setup lang="ts">
const isOpen = ref(false)
// Expose specific methods/state to parent
defineExpose({
open: () => { isOpen.value = true },
close: () => { isOpen.value = false },
isOpen: readonly(isOpen),
})
</script>