Learnixo

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>