Frontend Engineeringintermediate
TypeScript with Vue 3: Full Type Safety Across Your App
Integrate TypeScript throughout a Vue 3 application — typed component props and emits, typed Pinia stores, typed Vue Router, typed API clients, generic components, and strict tsconfig for maximum safety.
LearnixoApril 16, 20265 min read
Vue.jsTypeScriptVue 3Type SafetyFrontendComposition API
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>Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.