Vue.js con TypeScript

Vue.js e TypeScript formano una combinazione naturale. A partire da Vue 3, l'intero framework è stato riscritto in TypeScript, il che significa che il supporto ai tipi non è un'aggiunta esterna ma una caratteristica di prima classe. Questa guida copre in modo approfondito l'integrazione tra i due, dalla configurazione iniziale del progetto fino ai pattern avanzati di tipizzazione nei componenti, nel routing e nello state management.

Creazione del progetto

Lo strumento ufficiale per avviare un progetto Vue 3 è create-vue, basato su Vite. Il comando seguente genera un progetto con TypeScript già configurato, incluse le impostazioni di tsconfig.json e i file .vue pronti per la sintassi <script setup lang="ts">.

# Creazione di un nuovo progetto Vue con TypeScript
npm create vue@latest my-app

# Selezionare "Yes" alla domanda su TypeScript durante il wizard

cd my-app
npm install
npm run dev

La struttura che si ottiene contiene un file tsconfig.json alla radice, un file tsconfig.app.json dedicato al codice sorgente e un env.d.ts che dichiara i tipi per i moduli .vue. Questo ultimo file è essenziale: senza di esso, TypeScript non saprebbe come trattare le importazioni dei Single File Component.

// env.d.ts — dichiarazione dei tipi per i moduli .vue
/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

Il file tsconfig.app.json merita attenzione. Le opzioni chiave sono strict: true, che abilita tutti i controlli rigorosi di TypeScript, e l'inclusione del plugin @vue/tsconfig che fornisce impostazioni ottimali per i progetti Vue.

{
  "extends": "@vue/tsconfig/tsconfig.dom.json",
  "compilerOptions": {
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

Componenti con la Composition API

La Composition API rappresenta il modo consigliato per scrivere componenti Vue con TypeScript. La macro defineProps accetta un parametro generico che descrive la forma delle props, e il compilatore genera automaticamente le definizioni di runtime corrispondenti.

<script setup lang="ts">
// Definizione delle props con tipo generico
const props = defineProps<{
  title: string
  count: number
  isActive?: boolean
}>()

// Definizione degli eventi emessi dal componente
const emit = defineEmits<{
  (e: 'update', value: number): void
  (e: 'close'): void
}>()

function handleClick(): void {
  // Incremento del contatore e notifica al componente padre
  emit('update', props.count + 1)
}
</script>

<template>
  <button @click="handleClick">
    {{ title }}: {{ count }}
  </button>
</template>

Quando le props richiedono valori predefiniti, si utilizza withDefaults in combinazione con defineProps. Questa funzione preserva la tipizzazione completa e consente di specificare i fallback per le proprietà opzionali.

<script setup lang="ts">
interface NotificationProps {
  message: string
  type?: 'info' | 'warning' | 'error'
  duration?: number
  dismissible?: boolean
}

// Assegnazione dei valori predefiniti alle props opzionali
const props = withDefaults(defineProps<NotificationProps>(), {
  type: 'info',
  duration: 5000,
  dismissible: true,
})
</script>

Reactive state e ref tipizzate

Le funzioni ref e reactive supportano la tipizzazione esplicita tramite generics. Nella maggior parte dei casi, TypeScript è in grado di inferire il tipo dal valore iniziale, ma ci sono situazioni in cui l'annotazione esplicita è necessaria: quando il valore iniziale è null, quando si lavora con union types o quando si vuole restringere il tipo rispetto a quello inferito.

<script setup lang="ts">
import { ref, reactive, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
}

// Tipo inferito automaticamente come Ref<number>
const counter = ref(0)

// Tipo esplicito necessario perché il valore iniziale è null
const currentUser = ref<User | null>(null)

// Stato reattivo con tipizzazione tramite interfaccia
const formState = reactive<{
  username: string
  errors: string[]
  isSubmitting: boolean
}>({
  username: '',
  errors: [],
  isSubmitting: false,
})

// Computed con tipo di ritorno inferito automaticamente
const isLoggedIn = computed(() => currentUser.value !== null)

// Computed con tipo di ritorno esplicito
const greeting = computed<string>(() => {
  // Costruzione del messaggio di benvenuto
  if (currentUser.value) {
    return `Benvenuto, ${currentUser.value.name}`
  }
  return 'Benvenuto, ospite'
})

async function fetchUser(id: number): Promise<void> {
  // Simulazione di una chiamata API per ottenere i dati dell'utente
  const response = await fetch(`/api/users/${id}`)
  const data: User = await response.json()
  currentUser.value = data
}
</script>

Tipizzazione dei template ref

Quando si accede a un elemento del DOM o a un componente figlio tramite ref nel template, il tipo deve essere annotato esplicitamente. Per gli elementi HTML nativi si usa il tipo DOM corrispondente. Per i componenti Vue si utilizza il tipo InstanceType combinato con typeof.

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import UserProfile from './UserProfile.vue'

// Riferimento a un elemento HTML nativo
const inputElement = ref<HTMLInputElement | null>(null)

// Riferimento a un'istanza di componente Vue
const profileComponent = ref<InstanceType<typeof UserProfile> | null>(null)

onMounted(() => {
  // Accesso sicuro all'elemento con controllo di nullità
  inputElement.value?.focus()
})
</script>

<template>
  <input ref="inputElement" type="text" />
  <UserProfile ref="profileComponent" />
</template>

Composables tipizzati

I composables sono funzioni riutilizzabili che incapsulano logica reattiva. Rappresentano l'equivalente Vue dei custom hooks di React. Con TypeScript, è possibile tipizzare sia i parametri che il valore di ritorno, ottenendo un'API chiara e autocompletamento completo nell'editor.

// composables/useFetch.ts — composable generico per le chiamate HTTP
import { ref, watchEffect, type Ref } from 'vue'

interface UseFetchResult<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  isLoading: Ref<boolean>
  refetch: () => Promise<void>
}

export function useFetch<T>(url: Ref<string> | string): UseFetchResult<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const isLoading = ref(false)

  async function fetchData(): Promise<void> {
    // Reset dello stato prima di ogni nuova richiesta
    isLoading.value = true
    error.value = null

    try {
      const resolvedUrl = typeof url === 'string' ? url : url.value
      const response = await fetch(resolvedUrl)

      if (!response.ok) {
        throw new Error(`Errore HTTP: ${response.status}`)
      }

      // Parsing della risposta JSON con il tipo generico
      data.value = await response.json()
    } catch (err) {
      // Gestione dell'errore con narrowing del tipo
      error.value = err instanceof Error ? err.message : 'Errore sconosciuto'
    } finally {
      isLoading.value = false
    }
  }

  // Esecuzione automatica al montaggio e quando l'URL cambia
  watchEffect(() => {
    fetchData()
  })

  return { data, error, isLoading, refetch: fetchData }
}

L'utilizzo del composable all'interno di un componente beneficia della type inference completa. Il tipo generico passato a useFetch si propaga fino alla variabile data.

<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

interface Article {
  id: number
  title: string
  body: string
  publishedAt: string
}

// Il tipo di data sarà Ref<Article[] | null>
const { data: articles, error, isLoading } = useFetch<Article[]>('/api/articles')
</script>

<template>
  <p v-if="isLoading">Caricamento...</p>
  <p v-else-if="error">{{ error }}</p>
  <ul v-else-if="articles">
    <li v-for="article in articles" :key="article.id">
      {{ article.title }}
    </li>
  </ul>
</template>

Un secondo esempio di composable illustra come gestire lo stato di un form con validazione tipizzata.

// composables/useForm.ts — composable per la gestione dei form
import { reactive, computed, type ComputedRef } from 'vue'

type ValidationRule<T> = (value: T) => string | null

interface UseFormResult<T extends Record<string, unknown>> {
  fields: T
  errors: Record<keyof T, string | null>
  isValid: ComputedRef<boolean>
  validate: () => boolean
  reset: () => void
}

export function useForm<T extends Record<string, unknown>>(
  initialValues: T,
  rules: Partial<Record<keyof T, ValidationRule<any>>>
): UseFormResult<T> {
  // Creazione dello stato reattivo del form
  const fields = reactive({ ...initialValues }) as T

  const errors = reactive(
    Object.keys(initialValues).reduce(
      (acc, key) => ({ ...acc, [key]: null }),
      {} as Record<keyof T, string | null>
    )
  )

  function validate(): boolean {
    let valid = true

    // Iterazione su tutte le regole di validazione
    for (const [key, rule] of Object.entries(rules)) {
      if (rule) {
        const result = (rule as ValidationRule<unknown>)(fields[key as keyof T])
        ;(errors as Record<string, string | null>)[key] = result
        if (result !== null) valid = false
      }
    }

    return valid
  }

  // Computed che riflette la validità complessiva del form
  const isValid = computed(() =>
    Object.values(errors).every((e) => e === null)
  )

  function reset(): void {
    // Ripristino dei valori iniziali
    Object.assign(fields, initialValues)
    for (const key of Object.keys(errors)) {
      ;(errors as Record<string, string | null>)[key] = null
    }
  }

  return { fields, errors, isValid, validate, reset }
}

Provide e Inject tipizzati

Il meccanismo di provide/inject consente la comunicazione tra componenti senza passare props attraverso ogni livello dell'albero. Con TypeScript, si definisce una InjectionKey tipizzata che garantisce la coerenza tra il valore fornito e quello iniettato.

// keys.ts — definizione delle chiavi di iniezione tipizzate
import type { InjectionKey, Ref } from 'vue'

export interface ThemeConfig {
  primaryColor: string
  fontFamily: string
  borderRadius: string
  isDark: boolean
}

// Chiave tipizzata per il tema dell'applicazione
export const ThemeKey: InjectionKey<Ref<ThemeConfig>> = Symbol('theme')

// Chiave tipizzata per la funzione di notifica
export const NotifyKey: InjectionKey<(message: string) => void> = Symbol('notify')
<!-- Componente padre che fornisce il tema -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { ThemeKey, type ThemeConfig } from '@/keys'

// Creazione dello stato reattivo del tema
const theme = ref<ThemeConfig>({
  primaryColor: '#3b82f6',
  fontFamily: 'system-ui, sans-serif',
  borderRadius: '8px',
  isDark: false,
})

provide(ThemeKey, theme)
</script>
<!-- Componente figlio che consuma il tema iniettato -->
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeKey } from '@/keys'

// Iniezione con valore predefinito di fallback
const theme = inject(ThemeKey)

if (!theme) {
  throw new Error('ThemeKey non fornita. Verificare il componente padre.')
}
</script>

<template>
  <p :style="{ color: theme.primaryColor }">
    Testo con il colore del tema
  </p>
</template>

Vue Router con TypeScript

Vue Router dalla versione 4 offre un supporto TypeScript integrato. Le rotte possono essere tipizzate tramite l'interfaccia RouteRecordRaw, e i meta-campi personalizzati si dichiarano estendendo il modulo vue-router.

// router/index.ts — configurazione del router con tipi
import {
  createRouter,
  createWebHistory,
  type RouteRecordRaw,
} from 'vue-router'

// Estensione dei meta-campi delle rotte
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    roles?: Array<'admin' | 'editor' | 'viewer'>
    title?: string
  }
}

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/HomeView.vue'),
    meta: {
      title: 'Pagina principale',
    },
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/DashboardView.vue'),
    meta: {
      requiresAuth: true,
      roles: ['admin', 'editor'],
      title: 'Pannello di controllo',
    },
  },
  {
    path: '/users/:id',
    name: 'UserDetail',
    component: () => import('@/views/UserDetailView.vue'),
    meta: {
      requiresAuth: true,
      title: 'Dettaglio utente',
    },
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

// Navigation guard con accesso tipizzato ai meta-campi
router.beforeEach((to, _from) => {
  if (to.meta.requiresAuth) {
    const isAuthenticated = checkAuth()
    if (!isAuthenticated) {
      return { name: 'Home' }
    }
  }
})

function checkAuth(): boolean {
  // Verifica dello stato di autenticazione
  return !!localStorage.getItem('auth_token')
}

export default router

All'interno dei componenti, i composable useRoute e useRouter restituiscono oggetti già tipizzati. Per i parametri della rotta, tuttavia, è spesso utile effettuare un cast esplicito perché Vue Router li tratta come string | string[].

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'

const route = useRoute()
const router = useRouter()

// Cast esplicito del parametro della rotta
const userId = computed(() => Number(route.params.id))

async function navigateToUser(id: number): Promise<void> {
  // Navigazione programmatica con parametri tipizzati
  await router.push({
    name: 'UserDetail',
    params: { id: String(id) },
  })
}
</script>

Pinia: state management tipizzato

Pinia è il gestore di stato ufficiale di Vue 3 e offre un'esperienza TypeScript eccellente. A differenza di Vuex, non richiede wrapper complessi o tipi ausiliari. Ogni store viene definito tramite la funzione defineStore, e sia lo stato che le azioni e i getter risultano completamente tipizzati.

// stores/auth.ts — store di autenticazione con Pinia
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export interface AuthUser {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
  avatarUrl: string | null
}

interface LoginCredentials {
  email: string
  password: string
}

interface AuthResponse {
  user: AuthUser
  token: string
  expiresAt: number
}

// Definizione dello store con la sintassi "setup"
export const useAuthStore = defineStore('auth', () => {
  // Stato reattivo dello store
  const user = ref<AuthUser | null>(null)
  const token = ref<string | null>(null)
  const isLoading = ref(false)

  // Getter derivati dallo stato
  const isAuthenticated = computed(() => token.value !== null)
  const isAdmin = computed(() => user.value?.role === 'admin')
  const displayName = computed(() => user.value?.name ?? 'Ospite')

  async function login(credentials: LoginCredentials): Promise<void> {
    // Invio delle credenziali al server
    isLoading.value = true

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials),
      })

      if (!response.ok) {
        throw new Error('Credenziali non valide')
      }

      const data: AuthResponse = await response.json()

      // Aggiornamento dello stato dopo il login
      user.value = data.user
      token.value = data.token
      localStorage.setItem('auth_token', data.token)
    } finally {
      isLoading.value = false
    }
  }

  function logout(): void {
    // Pulizia completa dello stato di autenticazione
    user.value = null
    token.value = null
    localStorage.removeItem('auth_token')
  }

  function hasRole(role: AuthUser['role']): boolean {
    // Verifica se l'utente possiede il ruolo specificato
    return user.value?.role === role
  }

  return {
    user,
    token,
    isLoading,
    isAuthenticated,
    isAdmin,
    displayName,
    login,
    logout,
    hasRole,
  }
})

Lo store si utilizza nei componenti importandolo e invocandolo. Pinia garantisce che tutte le proprietà mantengano la reattività e la tipizzazione.

<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { storeToRefs } from 'pinia'

const authStore = useAuthStore()

// Destrutturazione con mantenimento della reattività
const { user, isAuthenticated, displayName } = storeToRefs(authStore)

async function handleLogin(): Promise<void> {
  // Tentativo di accesso con le credenziali del form
  await authStore.login({
    email: 'utente@esempio.it',
    password: 'password123',
  })
}
</script>

<template>
  <p v-if="isAuthenticated">Ciao, {{ displayName }}</p>
  <button v-else @click="handleLogin">Accedi</button>
</template>

Componenti generici

Vue 3.3 ha introdotto la direttiva generic nel tag <script setup>, rendendo possibile la creazione di componenti con parametri di tipo. Questo consente di costruire componenti riutilizzabili che adattano la propria tipizzazione al contesto d'uso.

<!-- GenericList.vue — componente lista generico e riutilizzabile -->
<script setup lang="ts" generic="T extends { id: number | string }">
// Il tipo T è vincolato ad avere una proprietà id
defineProps<{
  items: T[]
  selectedId?: T['id']
}>()

const emit = defineEmits<{
  (e: 'select', item: T): void
}>()
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      :class="{ active: item.id === selectedId }"
      @click="emit('select', item)"
    >
      <!-- Slot con accesso tipizzato all'elemento -->
      <slot :item="item" />
    </li>
  </ul>
</template>

Quando si utilizza il componente generico, TypeScript inferisce il tipo T dalla prop items e lo propaga allo slot e all'evento select.

<script setup lang="ts">
import GenericList from './GenericList.vue'

interface Product {
  id: number
  name: string
  price: number
  category: string
}

const products: Product[] = [
  { id: 1, name: 'Tastiera meccanica', price: 129, category: 'Periferiche' },
  { id: 2, name: 'Monitor 4K', price: 499, category: 'Display' },
]

function handleSelect(product: Product): void {
  // Il tipo del parametro è inferito come Product
  console.log(`Selezionato: ${product.name} — ${product.price} EUR`)
}
</script>

<template>
  <GenericList :items="products" @select="handleSelect">
    <template #default="{ item }">
      <!-- item è tipizzato come Product grazie al generic -->
      <span>{{ item.name }} — {{ item.price }} EUR</span>
    </template>
  </GenericList>
</template>

Gestione degli eventi del DOM

Nei gestori di eventi definiti nel template, il parametro event non è automaticamente tipizzato. Per accedere alle proprietà specifiche dell'evento, occorre effettuare un'asserzione di tipo oppure tipizzare esplicitamente il parametro della funzione handler.

<script setup lang="ts">
// Gestore con tipizzazione esplicita del parametro
function handleInput(event: Event): void {
  // Asserzione di tipo per accedere al valore dell'input
  const target = event.target as HTMLInputElement
  console.log('Valore inserito:', target.value)
}

function handleKeyDown(event: KeyboardEvent): void {
  // Accesso diretto alle proprietà specifiche di KeyboardEvent
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault()
    submitForm()
  }
}

function handleDrag(event: DragEvent): void {
  // Gestione del trascinamento con accesso al dataTransfer
  const files = event.dataTransfer?.files
  if (files && files.length > 0) {
    processFiles(files)
  }
}

function submitForm(): void {
  // Invio del form
}

function processFiles(files: FileList): void {
  // Elaborazione dei file caricati
  for (const file of files) {
    console.log(`File: ${file.name}, dimensione: ${file.size} byte`)
  }
}
</script>

<template>
  <input @input="handleInput" @keydown="handleKeyDown" />
  <p @drop.prevent="handleDrag" @dragover.prevent>Trascina qui i file</p>
</template>

Watchers tipizzati

Le funzioni watch e watchEffect inferiscono automaticamente il tipo del valore osservato. Nel caso di watch, i parametri del callback corrispondono al nuovo e al vecchio valore della sorgente reattiva, e TypeScript ne conosce il tipo senza annotazioni aggiuntive.

<script setup lang="ts">
import { ref, watch } from 'vue'

interface SearchFilters {
  query: string
  category: string | null
  page: number
}

const filters = ref<SearchFilters>({
  query: '',
  category: null,
  page: 1,
})

// Osservazione profonda dell'intero oggetto filtri
watch(
  filters,
  (newFilters, oldFilters) => {
    // newFilters e oldFilters sono tipizzati come SearchFilters
    if (newFilters.query !== oldFilters.query) {
      // Reset della pagina quando cambia la query di ricerca
      filters.value.page = 1
    }
    performSearch(newFilters)
  },
  { deep: true }
)

// Osservazione di una singola proprietà derivata
watch(
  () => filters.value.page,
  (newPage, oldPage) => {
    // newPage e oldPage sono tipizzati come number
    console.log(`Navigazione dalla pagina ${oldPage} alla pagina ${newPage}`)
  }
)

function performSearch(filters: SearchFilters): void {
  // Esecuzione della ricerca con i filtri forniti
  console.log('Ricerca con filtri:', filters)
}
</script>

Slot tipizzati

La macro defineSlots, introdotta in Vue 3.3, consente di dichiarare la firma degli slot di un componente. Questo fornisce il controllo dei tipi sia nel componente che definisce gli slot sia in quello che li utilizza.

<!-- DataTable.vue — tabella dati con slot tipizzati -->
<script setup lang="ts" generic="T extends Record<string, unknown>">
defineProps<{
  rows: T[]
  columns: Array<keyof T>
}>()

// Dichiarazione degli slot con i rispettivi tipi di parametro
defineSlots<{
  header(props: { columns: Array<keyof T> }): unknown
  row(props: { item: T; index: number }): unknown
  empty(props: {}): unknown
}>()
</script>

<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="columns" />
      </tr>
    </thead>
    <tbody>
      <template v-if="rows.length">
        <tr v-for="(item, index) in rows" :key="index">
          <slot name="row" :item="item" :index="index" />
        </tr>
      </template>
      <tr v-else>
        <slot name="empty" />
      </tr>
    </tbody>
  </table>
</template>

Integrazione con librerie esterne

Quando si utilizzano librerie JavaScript che non forniscono dichiarazioni di tipo, è necessario creare un file .d.ts per dichiararle manualmente. Questo evita errori di compilazione e permette di definire le API della libreria in modo preciso.

// types/legacy-chart.d.ts — dichiarazioni per una libreria senza tipi
declare module 'legacy-chart-lib' {
  export interface ChartOptions {
    width: number
    height: number
    animate?: boolean
    colors?: string[]
  }

  export interface DataPoint {
    label: string
    value: number
  }

  export class Chart {
    constructor(element: HTMLElement, options: ChartOptions)
    setData(points: DataPoint[]): void
    render(): void
    destroy(): void
  }
}

Per le librerie che espongono plugin Vue, si estende l'interfaccia ComponentCustomProperties per aggiungere proprietà globali accessibili in ogni componente.

// types/global.d.ts — estensione delle proprietà globali dei componenti
import type { AxiosInstance } from 'axios'

declare module 'vue' {
  interface ComponentCustomProperties {
    $http: AxiosInstance
    $formatDate: (date: Date, locale?: string) => string
    $appVersion: string
  }
}

// Necessario per rendere il file un modulo
export {}

Pattern avanzato: composable con overload

Un composable complesso puo' esporre API diverse in base ai parametri ricevuti. Gli overload di TypeScript consentono di modellare questo comportamento con precisione, offrendo una developer experience fluida.

// composables/useStorage.ts — composable con overload per localStorage
import { ref, watch, type Ref } from 'vue'

// Overload: con valore predefinito, il ritorno non è mai null
export function useStorage<T>(key: string, defaultValue: T): Ref<T>
// Overload: senza valore predefinito, il ritorno può essere null
export function useStorage<T>(key: string): Ref<T | null>

// Implementazione che copre entrambi gli overload
export function useStorage<T>(key: string, defaultValue?: T): Ref<T | null> {
  // Lettura del valore iniziale da localStorage
  const storedValue = localStorage.getItem(key)

  let initialValue: T | null
  if (storedValue !== null) {
    try {
      initialValue = JSON.parse(storedValue) as T
    } catch {
      initialValue = defaultValue ?? null
    }
  } else {
    initialValue = defaultValue ?? null
  }

  const data = ref(initialValue) as Ref<T | null>

  // Sincronizzazione automatica con localStorage ad ogni modifica
  watch(
    data,
    (newValue) => {
      if (newValue === null) {
        localStorage.removeItem(key)
      } else {
        localStorage.setItem(key, JSON.stringify(newValue))
      }
    },
    { deep: true }
  )

  return data
}
// Esempio d'uso degli overload

// Il tipo è Ref<string> — mai null grazie al valore predefinito
const locale = useStorage('locale', 'it-IT')

// Il tipo è Ref<{ darkMode: boolean } | null> — potenzialmente null
const preferences = useStorage<{ darkMode: boolean }>('preferences')

Utility types per Vue

Vue espone diversi tipi utilitari che semplificano la definizione di tipi complessi. Conoscerli consente di scrivere codice piu' idiomatico e leggibile.

import type {
  PropType,
  ExtractPropTypes,
  ExtractPublicPropTypes,
  ComponentPublicInstance,
  VNode,
  Slot,
} from 'vue'

// PropType: utile nella Options API per tipizzare le props complesse
const optionsApiProps = {
  config: {
    type: Object as PropType<{ timeout: number; retries: number }>,
    required: true as const,
  },
  handler: {
    type: Function as PropType<(event: string) => void>,
    default: () => () => {},
  },
}

// ExtractPropTypes: estrae il tipo delle props dalla definizione
type ResolvedProps = ExtractPropTypes<typeof optionsApiProps>

// Tipo per una funzione di rendering personalizzata
type RenderFunction = (props: { items: string[] }) => VNode | VNode[]

// Tipo per uno slot con parametri tipizzati
type TypedSlot = Slot<{ item: string; index: number }>

Testing con TypeScript

I test dei componenti Vue beneficiano di TypeScript grazie al controllo statico dei tipi sulle props, sugli eventi emessi e sulle interazioni simulate. La libreria @vue/test-utils fornisce wrapper completamente tipizzati.

// __tests__/NotificationBanner.spec.ts — test di un componente
import { describe, it, expect } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import NotificationBanner from '@/components/NotificationBanner.vue'

interface NotificationProps {
  message: string
  type?: 'info' | 'warning' | 'error'
  dismissible?: boolean
}

describe('NotificationBanner', () => {
  function createWrapper(
    props: NotificationProps
  ): VueWrapper<InstanceType<typeof NotificationBanner>> {
    // Creazione del wrapper con le props tipizzate
    return mount(NotificationBanner, { props })
  }

  it('visualizza il messaggio corretto', () => {
    const wrapper = createWrapper({ message: 'Operazione completata' })
    expect(wrapper.text()).toContain('Operazione completata')
  })

  it('emette l\'evento close al click sul pulsante di chiusura', async () => {
    const wrapper = createWrapper({
      message: 'Avviso',
      dismissible: true,
    })

    // Simulazione del click sul pulsante
    await wrapper.find('button').trigger('click')

    // Verifica che l'evento sia stato emesso
    const emitted = wrapper.emitted('close')
    expect(emitted).toBeTruthy()
    expect(emitted).toHaveLength(1)
  })

  it('applica la classe CSS corrispondente al tipo', () => {
    const wrapper = createWrapper({
      message: 'Errore critico',
      type: 'error',
    })

    // Verifica della classe applicata
    expect(wrapper.classes()).toContain('notification--error')
  })
})

Considerazioni sulla configurazione di tsconfig

In un progetto Vue di produzione, alcune opzioni del compilatore TypeScript meritano una configurazione attenta. L'opzione strict abilita un insieme di controlli che insieme migliorano la sicurezza del codice: strictNullChecks impedisce l'accesso a valori potenzialmente null, noImplicitAny richiede che ogni variabile abbia un tipo esplicito o inferibile, e strictFunctionTypes rende controvarianti le posizioni dei parametri delle funzioni.

L'opzione skipLibCheck impostata a true velocizza la compilazione saltando il controllo dei file .d.ts delle dipendenze, il che puo' essere utile in progetti con molte librerie di terze parti. L'opzione moduleResolution dovrebbe essere impostata su "bundler" nei progetti basati su Vite, dato che questo valore riflette il comportamento effettivo del bundler.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "useDefineForClassFields": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  }
}

Conclusione

L'adozione di TypeScript in un progetto Vue 3 porta vantaggi concreti e misurabili: errori intercettati prima del runtime, refactoring assistito dall'editor, documentazione implicita tramite i tipi e una developer experience complessivamente superiore. La Composition API e le macro come defineProps, defineEmits e defineSlots sono state progettate con TypeScript come cittadino di prima classe, rendendo l'integrazione fluida e priva di attrito. Pinia e Vue Router seguono la stessa filosofia, con API che si tipizzano quasi da sole. I composables generici e gli overload di funzione aprono la strada a pattern avanzati che sarebbero impraticabili senza un sistema di tipi statico. La combinazione di Vue e TypeScript, oggi, non rappresenta un compromesso ma uno standard produttivo.