Back to blog
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
Share:𝕏

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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.