Vue 3 con TypeScript nel 2026: props, emits e composable type-safe
Padroneggiare componenti Vue 3 type-safe con TypeScript: defineProps generico, defineEmits a tupla, composable tipizzati, defineModel e InjectionKey, con domande da colloquio.

Nel 2026 Vue 3 con TypeScript offre un controllo dei tipi completamente statico su props, eventi e logica riutilizzabile. La sintassi <script setup> combinata con le macro del compilatore come defineProps e defineEmits trasforma quelle che un tempo erano definizioni valide solo a runtime in contratti verificati in fase di compilazione. Questa guida illustra i pattern type-safe di cui ogni sviluppatrice e sviluppatore Vue ha bisogno per il codice di produzione e per i colloqui tecnici.
In Vue 3.5+ il generico defineProps<T>() inferisce i tipi delle props direttamente da un'interfaccia TypeScript — niente campi type a runtime, nessun cast con PropType. Insieme alla destrutturazione reattiva delle props, i valori predefiniti stanno inline: const { size = 'md' } = defineProps<Props>().
Tipizzare le props con defineProps e le interfacce TypeScript
La dichiarazione delle props a runtime (props: { title: String }) funziona, ma duplica le informazioni di tipo e perde precisione. Tipi union, oggetti annidati e firme di funzione non possono essere espressi in modo pulito. La forma generica di defineProps risolve il problema prendendo la struttura da un tipo TypeScript, che il compilatore Vue converte poi automaticamente nella corretta dichiarazione a runtime.
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
userId: number // required primitive
name: string
role: 'admin' | 'member' // union type, impossible with runtime props
tags?: string[] // optional array
onSelect?: (id: number) => void // typed callback prop
}
const props = defineProps<Props>()
</script>
<template>
<article @click="props.onSelect?.(props.userId)">
<h3>{{ props.name }}</h3>
<span>{{ props.role }}</span>
</article>
</template>La prop role è ora vincolata a due stringhe letterali, per cui passare role="guest" fa fallire la build. È proprio questo il motivo principale per ricorrere a defineProps con TypeScript: la guida ufficiale a Vue e TypeScript tratta la forma generica come impostazione predefinita per i progetti con <script setup>.
Valori predefiniti delle props: withDefaults vs. destrutturazione reattiva
Le props di solo tipo non dispongono di per sé di alcun meccanismo a runtime per i valori predefiniti. Storicamente la risposta era withDefaults, che avvolge la macro e unisce un oggetto di valori predefiniti:
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}
// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
})
</script>Vue 3.5 ha stabilizzato la destrutturazione reattiva delle props, ormai il pattern più conciso. Destrutturare il risultato di defineProps e assegnare un valore predefinito nella stessa istruzione mantiene tutto pienamente reattivo: il compilatore riscrive ogni accesso a props.x dietro le quinte.
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
label: string
color?: 'green' | 'red'
outlined?: boolean
}
// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>
<template>
<span :class="[color, { outlined }]">{{ label }}</span>
</template>Le props destrutturate restano reattive nel template e in computed, ma passare un valore destrutturato direttamente a watch o a un composable lo legge una sola volta e interrompe il collegamento reattivo. In questi casi conviene racchiuderlo in un getter — watch(() => color, ...) — oppure convertirlo con toRef(props, 'color') quando a valle serve una ref.
Questa è una trappola frequente nei colloqui: chi si candida presume che la variabile destrutturata sia un valore semplice, mentre in realtà il compilatore ha reindirizzato ogni lettura a props.color.
Eventi type-safe con defineEmits
Gli eventi meritano lo stesso rigore delle props. Il generico defineEmits descrive ogni nome di evento e il relativo payload come una tupla, offrendo al componente genitore il completamento automatico e al componente figlio i controlli in fase di compilazione che gli argomenti emessi siano quelli corretti.
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
search: [query: string]
clear: [] // no payload
select: [id: number, label: string] // multiple args
}>()
function onSubmit(value: string) {
emit('search', value) // ✅ typed
// emit('search', 42) // ❌ compile error: number not assignable to string
}
</script>Questa forma a tupla ha sostituito la vecchia sintassi con firma di chiamata ((e: 'search', q: string): void) perché si legge meglio e supporta più eventi senza overload. Quando un evento non trasporta dati, una tupla vuota [] lo documenta in modo esplicito. Abbinare eventi tipizzati a props tipizzate produce componenti la cui intera interfaccia pubblica è verificabile prima del runtime — la stessa disciplina trattata nella guida alla Composition API di Vue.
Tipizzare i composable per la logica riutilizzabile
I composable sono semplici funzioni, quindi seguono le normali regole di TypeScript — ma alcune convenzioni li mantengono ergonomici. Conviene restituire esplicitamente i tipi Ref quando l'inferenza non è ovvia e usare i generici quando un composable incapsula dati arbitrari come una risposta API.
import { ref, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
}
// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(true)
fetch(url)
.then((r) => r.json())
.then((json: T) => { data.value = json })
.catch((e: Error) => { error.value = e })
.finally(() => { loading.value = false })
return { data, error, loading }
}Nel punto di chiamata il parametro generico rende data completamente tipizzato senza alcuna annotazione aggiuntiva:
interface User { id: number; name: string }
// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')Annotare esplicitamente l'oggetto restituito (UseFetchReturn<T>) vale le poche righe in più: documenta il contratto, evita fughe accidentali di ref interne e offre a chi consuma il composable un unico tipo da importare. Per pattern di composable più avanzati come overload degli argomenti e cleanup consapevole del ciclo di vita, si veda la guida avanzata ai composable di Vue.
Pronto a superare i tuoi colloqui su Vue.js / Nuxt.js?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
Tipizzare defineModel per il binding bidirezionale
defineModel, stabile da Vue 3.4, riduce la vecchia coppia formata dalla prop modelValue e dall'emit update:modelValue a un'unica ref scrivibile. Il suo parametro generico tipizza entrambe le direzioni del binding in un colpo solo.
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })
function increment() {
model.value++ // writing back updates the parent
}
</script>
<template>
<input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
<button @click="increment">+1</button>
</template>I modelli con nome — defineModel<string>('title') — vengono mappati su v-model:title e ricevono la stessa tipizzazione. Questo elimina un'intera categoria di bug da payload non corrispondenti che il pattern manuale prop-più-emit tendeva a nascondere.
Tipizzare le template ref e le istanze di componente
Accedere a un nodo del DOM o a un componente figlio tramite una ref è il punto in cui il codice Vue non tipizzato più spesso ripiega su any. La soluzione è parametrizzare useTemplateRef (Vue 3.5+) o la ref stessa con il tipo dell'elemento, così che l'accesso alle proprietà venga controllato rispetto alla reale interfaccia del DOM.
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')
onMounted(() => {
// .focus() and .select() are known because the element type is explicit
inputRef.value?.focus()
})
</script>
<template>
<input ref="field" placeholder="Type to search" />
</template>Per un riferimento a un componente figlio, InstanceType<typeof Child> estrae il tipo pubblico del componente, esponendo tutto ciò che il figlio ha dichiarato tramite defineExpose. In questo modo le chiamate di metodo dal genitore al figlio restano completamente controllate anziché indovinate.
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'
// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')
function launch() {
modal.value?.open() // ✅ checked against Modal's defineExpose
}
</script>provide e inject type-safe con InjectionKey
L'iniezione di dipendenze attraverso l'albero dei componenti perde le informazioni di tipo a meno che la chiave non le trasporti. InjectionKey<T> è un symbol tipizzato che lega il tipo di un valore alla sua chiave, così provide e inject restano sincronizzati senza cast manuali.
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })
// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()Fornire un valore la cui struttura non corrisponde a ThemeContext è un errore di compilazione nel punto di iniezione, intercettando così le divergenze tra componenti distanti prima che vengano rilasciate.
Domande frequenti nei colloqui su Vue e TypeScript
Chi conduce i colloqui verifica se la persona candidata comprende il confine tra i tipi in fase di compilazione e il comportamento a runtime. Alcune domande ricorrenti e le loro risposte incisive:
- Perché preferire
defineProps<T>()alla forma con oggetto a runtime? Le props generiche esprimono tipi union, firme di funzione e strutture annidate che le dichiarazioni a runtime non possono rappresentare, ed eliminano le definizioni duplicate di tipo e runtime. - Le props destrutturate sono reattive? Sì, in Vue 3.5+, perché il compilatore riscrive l'accesso a
props.x. Ma i valori destrutturati passati awatcho a un composable vengono letti una sola volta — occorre un getter otoRef. - Come si tipizza un evento emesso senza payload? Con una tupla vuota:
defineEmits<{ close: [] }>(). - Che cosa sostituisce
defineModel? La coppia formata dalla propmodelValuee dall'emitupdate:modelValue, unificata in un'unica ref scrivibile tipizzata.
Esercitarsi su queste domande con una vera banca di quesiti affina i riflessi che i colloqui premiano — il modulo di colloquio sui composable di Vue allena esattamente questi pattern. Anche il tooling conta: eseguire vue-tsc nella CI affinché gli errori di tipo blocchino i merge, e appoggiarsi al manuale di TypeScript quando entrano in gioco i generici dei composable. L'esperienza nell'editor è alimentata dal tooling ufficiale Volar di Vue, che legge queste macro per segnalare gli errori inline.
Conclusione
- Usare il generico
defineProps<Props>()per esprimere tipi union, campi opzionali e props di callback che le dichiarazioni a runtime non riescono a catturare. - In Vue 3.5+ preferire la destrutturazione reattiva delle props con valori predefiniti inline, e ricorrere a
withDefaultssolo quando un oggetto condiviso di valori predefiniti risulta più chiaro. - Tipizzare gli eventi con la forma a tupla di
defineEmitscosì che i payload vengano controllati in fase di compilazione sia nel figlio sia nel genitore. - Annotare esplicitamente i tipi di ritorno dei composable e usare i generici per inoltrare da un capo all'altro i tipi di dati forniti da chi chiama.
- Adottare
defineModel<T>()per il binding bidirezionale, così da ridurre il boilerplate prop-più-emit a un'unica ref tipizzata. - Eseguire
vue-tscnell'integrazione continua affinché le regressioni di tipo facciano fallire la build invece di raggiungere la produzione.
Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Tag
Condividi
Articoli correlati

Composable Avanzati in Vue 3: Pattern Riutilizzabili e Domande da Colloquio 2026
Guida completa ai composable avanzati di Vue 3 con pattern riutilizzabili, gestione asincrona degli errori, dependency injection con provide/inject e validazione dei form. Include domande da colloquio tecnico aggiornate al 2026.

Vue 3 Composition API: Guida completa alla reattività
Padroneggiare la Vue 3 Composition API con questa guida pratica. ref, reactive, computed, watch e composables per applicazioni Vue performanti.

Nuxt 4 nel 2026: nuova struttura di directory e migrazione da Nuxt 3
Analisi approfondita delle novità di Nuxt 4 nel 2026: struttura app/, data fetching con cache evoluto, reattività shallow, TypeScript rigoroso e guida alla migrazione graduale da Nuxt 3.