Appearance
Component patterns
Practical Vue + TS patterns you'll write in apps and explain in interviews.
1. Form with v-model
vue
<script setup lang="ts">
type FormModel = {
title: string
publish: boolean
}
const model = defineModel<FormModel>({ required: true })
const emit = defineEmits<{
save: [value: FormModel]
}>()
function onSubmit() {
emit('save', model.value)
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="model.title" />
<label>
<input v-model="model.publish" type="checkbox" />
Publish
</label>
<button type="submit">Save</button>
</form>
</template>If your Vue version predates defineModel, use the classic modelValue + update:modelValue emit pair with the same types.
2. Async list with discriminated status
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
type Product = { id: string; title: string; price: number }
type ListState =
| { status: 'loading' }
| { status: 'success'; items: Product[] }
| { status: 'error'; message: string }
const state = ref<ListState>({ status: 'loading' })
onMounted(async () => {
try {
const res = await fetch('/api/products')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const items = (await res.json()) as Product[]
state.value = { status: 'success', items }
} catch (err: unknown) {
state.value = {
status: 'error',
message: err instanceof Error ? err.message : 'Failed to load',
}
}
})
</script>
<template>
<p v-if="state.status === 'loading'">Loading…</p>
<p v-else-if="state.status === 'error'">{{ state.message }}</p>
<ul v-else>
<li v-for="item in state.items" :key="item.id">
{{ item.title }} - {{ item.price }}
</li>
</ul>
</template>Same discriminant pattern as the core unions lesson. Once you've got that, Vue is just the delivery vehicle.
3. Presentational card + typed emits
vue
<script setup lang="ts">
type Product = { id: string; title: string; price: number }
defineProps<{ product: Product }>()
const emit = defineEmits<{
addToCart: [productId: string]
}>()
</script>
<template>
<article>
<h3>{{ product.title }}</h3>
<p>{{ product.price }}</p>
<button type="button" @click="emit('addToCart', product.id)">Add</button>
</article>
</template>4. Composable-driven page
vue
<script setup lang="ts">
import { useFetch } from './useFetch'
type Product = { id: string; title: string }
const { data, loading, error, reload } = useFetch<Product>('/api/products/1')
</script>
<template>
<button type="button" @click="reload">Reload</button>
<p v-if="loading">Loading…</p>
<p v-else-if="error">{{ error }}</p>
<p v-else-if="data">{{ data.title }}</p>
</template>Interview notes
- Props vs emits: props configure; emits notify. Type both so parents can't pass wrong payloads.
- When inference fails: annotate
ref/computed, usedefineProps<T>(), type emit maps, avoid untyped[]/nullinitials. vue-tscin CI: Volar in the editor is not enough for merges - runvue-tsc --noEmitliketscin the core setup lesson.
Common pitfall
Optional boolean props without defaults become boolean | undefined in script - use withDefaults when you need a real boolean.
Mini check
Question: Why prefer a discriminated ListState over { loading, items?, error? }?
Answer
Illegal combinations (loading + items, or error + items) are harder to represent. A discriminant makes each UI branch's fields required and others unavailable - fewer runtime surprises.
And that's the Vue track. You've got the core guide, a light React chapter, and this fuller Vue one - should be enough to talk TypeScript in FE interviews without drowning in type-theory rabbit holes.
Until next time folks!