17 — TypeScript with Vue

Typed Props — defineProps with Generics

TypedProps.vue
<script setup lang="ts">
import { type PropType } from 'vue'

// ── Generic type annotation (preferred for <script setup>) ──
// Pure TypeScript syntax — no runtime prop validation, but full type safety.
const props = defineProps<{
  userId: number
  role: 'admin' | 'user' | 'guest'  // string literal union
  tags?: string[]                     // optional array
  config?: { theme: string; lang: string }  // optional object
}>()

// ── withDefaults: provide defaults for optional props ──
// Required because TS interface defaults don't exist at runtime.
// The destructured values are typed correctly (role is the union, not string).
const { role = 'user', tags = [], config = { theme: 'dark', lang: 'en' } } = withDefaults(props, {
  role: 'user',
  tags: () => [],  // factory for arrays/objects (fresh per instance)
  config: () => ({ theme: 'dark', lang: 'en' }),
})

// ── Runtime validation (alternative: for prop validation at runtime) ──
// Use when you need runtime checks (not just compile-time TS types).
const props2 = defineProps({
  userId: { type: Number, required: true },
  role: {
    type: String as PropType<'admin' | 'user' | 'guest'>,  // cast for literal unions
    default: 'user',
    validator: (v: string) => ['admin', 'user', 'guest'].includes(v),
  },
})
</script>

Typed Emits — defineEmits with Function Signatures

TypedEmits.vue
<script setup lang="ts">
// ── Call signature style (preferred — full type safety on payloads) ──
const emit = defineEmits<{
  // Each property is a function signature: (event name, payload) => void
  (e: 'update', userId: number, changes: Partial<User>): void
  (e: 'delete', payload: { id: number; soft: boolean }): void
  (e: 'cancel'): void  // no payload
}>()

// ── Alternative: object syntax (3.3+) ──
const emit2 = defineEmits<{
  update: [userId: number, changes: Partial<User>]
  delete: [payload: { id: number; soft: boolean }]
  cancel: []
}>()

// ── Usage: type-checked at call site ──
function handleSave() {
  emit('update', props.userId, { name: newName.value })  // ✅ typed
  // emit('update', '42', {})  // ❌ TS error: '42' is string, not number
}
</script>

Typed Component Instance — defineExpose

TypedExpose.vue
<script setup lang="ts">
import { ref } from 'vue'

// ── defineExpose: type the public API of the component ──
// Parent using template ref gets typed access to exposed methods.
const count = ref(0)
const internalState = ref('private')  // NOT exposed — parent can't access

function increment() { count.value++ }
function reset() { count.value = 0 }
function getValue() { return count.value }  // return value, not ref

// ── Expose with return type inference ──
defineExpose({
  increment,  // () => void
  reset,      // () => void
  getValue,   // () => number
  count,      // Ref<number> — exposed as a ref, parent accesses via .value
})

// ── Parent usage (typed ref): ──────────────────────────
// const childRef = ref<{ increment: () => void; reset: () => void; getValue: () => number; count: Ref<number> } | null>(null)
// childRef.value?.increment()  // ✅ typed
// childRef.value?.count.value  // ✅ typed
typed-ref.ts
import { ref, type ComponentPublicInstance } from 'vue'
import TypedComponent from './TypedComponent.vue'

// ── Type the template ref using InstanceType ──────────
// This gives you intellisense on the component's exposed API.
const childRef = ref<ComponentPublicInstance<typeof TypedComponent> | null>(null)

// ── Or use the instance type directly: ──
// If the component uses defineExpose, the ref type includes exposed members.
// If not, you get the default ComponentPublicInstance (with $el, $emit, etc.)

// ── Generic component typing: ──────────────────────────
import type { VNode } from 'vue'
const vNode = ref<VNode | null>(null)  // for render function components

Typed provide/inject — InjectionKey

typed-provide-inject.ts
import { provide, inject, type InjectionKey, type Ref } from 'vue'

// ── InjectionKey: a Symbol that carries the TypeScript type ──
// Using Symbol (not string) prevents naming collisions across libraries.
// The generic parameter is the type of the provided value.
export const USER_CONTEXT: InjectionKey<{
  user: Ref<User | null>
  login: (credentials: Credentials) => Promise<void>
  logout: () => void
}> = Symbol('user-context')

// ── Provider: type-checked ──
export function provideUser() {
  const user = ref<User | null>(null)

  const context = {
    user,
    login: async (credentials: Credentials) => { /* ... */ },
    logout: () => { user.value = null },
  }

  provide(USER_CONTEXT, context)  // ✅ type matches InjectionKey<T>
  return context
}

// ── Consumer: typed, with fallback ──────────────────────
export function useUser() {
  const ctx = inject(USER_CONTEXT)
  if (!ctx) {
    throw new Error('useUser() must be called within a component that provides USER_CONTEXT')
  }
  return ctx  // type: { user: Ref<User | null>; login: ...; logout: () => void }
}

// ── inject with default (if not provided): ──
const ctx = inject(USER_CONTEXT, {
  user: ref(null),
  login: async () => {},
  logout: () => {},
})
// ctx is non-null (default used if no provider) — no guard needed.

Generic Composable — useFetch with Type Inference

generic-composable.ts
import { ref, type Ref, type MaybeRefOrGetter } from 'vue'

// ── Generic composable: type T is inferred from the fetcher's return type ──
// The caller doesn't need to specify T — it's inferred from the generic.
export function useFetch<T>(
  url: MaybeRefOrGetter<string>,
  options?: { transform?: (data: unknown) => T }
): { data: Ref<T | null>; loading: Ref<boolean>; error: Ref<Error | null> } {
  const data: Ref<T | null> = ref(null) as Ref<T | null>
  const loading = ref(true)
  const error = ref<Error | null>(null)

  // ... fetch logic ...

  return { data, loading, error }
}

// ── Usage: T is inferred ─────────────────────────────────
interface User { id: number; name: string }

// T is inferred as User from the transform's return type
const { data } = useFetch('/api/user', {
  transform: (raw): User => ({ id: raw.id, name: raw.name })
})
// data.value is User | null — fully typed

// ── Without transform, T defaults to unknown ────────────
const { data: rawData } = useFetch('/api/unknown')
// rawData.value is unknown | null — must narrow before use

Template Type Narrowing — v-if as Type Guard

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

type Result =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: User[] }

const result = ref<Result>({ status: 'loading' })
</script>

<template>
  <!-- ── v-if acts as a type guard in templates ── -->
  <!-- After v-if="result.status === 'success'", Vue's template compiler -->
  <!-- narrows `result` to the success variant — result.data is available. -->
  <div v-if="result.status === 'loading'">Loading…</div>

  <div v-else-if="result.status === 'error'">
    Error: {{ result.message }}  <!-- ✅ narrowed: message is accessible -->
  </div>

  <div v-else-if="result.status === 'success'">
    <div v-for="user in result.data" :key="user.id">  <!-- ✅ narrowed: data exists -->
      {{ user.name }}
    </div>
  </div>
</template>

Type-Safe defineModel (3.4+)

TypedModel.vue
<script setup lang="ts">
// ── defineModel with generic type ──
// Creates: modelValue prop (typed) + update:modelValue emit (typed)
const model = defineModel<string>()  // Ref<string> — writable, two-way

// ── Named model with type ──
const firstName = defineModel<string>('firstName')  // v-model:firstName
const lastName = defineModel<string>('lastName')

// ── Model with options ──
const count = defineModel<number>({
  default: 0,
  required: true,
})

// ── Model with custom modifiers (3.4+) ──
// v-model.capitalize → modelModifiers.capitalize
const text = defineModel<string>({
  set: (v) => v.charAt(0).toUpperCase() + v.slice(1),  // transform on write
})

// ── Usage in template: ──────────────────────────────────
// <input v-model="model" /> — model.value is string, two-way binding
// model.value = 'hello' — emits update:modelValue automatically
</script>

💡 Tips & Tricks

tips.ts
// ── 1. Type the $event in inline handlers ──
// @click="(e: MouseEvent) => handler(e)" — e is typed

// ── 2. ComponentInstance type for refs ──
import type { ComponentPublicInstance } from 'vue'
const ref = ref<ComponentPublicInstance | null>(null)

// ── 3. DefineEmit with object syntax (3.3+) is cleaner ──
const emit = defineEmits<{
  update: [id: number, data: Partial<User>]
  delete: [id: number]
}>()

// ── 4. Generic type for slots: use defineSlots ──
const slots = defineSlots<{
  default: (props: { item: T }) => any
  header: () => any
}>()

// ── 5. Template expression type checking ──
// Vue's template compiler checks template expressions against <script setup> types.
// {{ user.name }} where user: User → name is type-checked.
// {{ user.nonExistent }} → TS error in template (with Volar).

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. defineProps generic syntax: no default values at runtime ──
// defineProps<{ x?: string }>() → x is string | undefined, no default.
// withDefaults adds runtime defaults (required for optional props to have values).

// ── 2. PropType cast for literal unions in runtime validation ──
// type: String as PropType<'a' | 'b'> → TS knows it's the union, Vue checks string at runtime.
// Without cast, Vue sees String (general), not the literal union.

// ── 3. defineModel default value doesn't affect the parent's bound ref ──
// defineModel<string>({ default: 'x' }) — default used if parent doesn't pass v-model.
// If parent passes v-model="myRef", default is ignored — myRef controls the value.

// ── 4. inject() returns undefined if no provider — always guard or provide default ──
// const ctx = inject(KEY)  → T | undefined
// const ctx = inject(KEY, defaultValue)  → T (no undefined)

// ── 5. Template ref typing with generic components ──
// ref<typeof GenericComponent> doesn't capture the generic parameter.
// Use ComponentPublicInstance<typeof GenericComponent> for full typing.

// ── 6. Ref<T | null> vs Ref<T> — null assignment ──
// const x = ref<T | null>(null) — correct: starts as null, can be T later.
// const x = ref<T>(null) — TS error: null is not assignable to T.
// Always include | null in the ref type if the initial value is null.

🧠 Spot the Bug

A component's optional prop is undefined at runtime despite the parent passing a value.

TypeBug.vue
<!-- Child -->
<script setup lang="ts">
const props = defineProps<{
  title?: string
  count?: number
}>()
// No withDefaults — title and count are undefined at runtime if parent doesn't pass them
// But even if parent passes them, the TYPE says optional (string | undefined)
</script>

<!-- Parent -->
<template>
  <Child :title="hello" />  <!-- title is passed, count is not -->
</template>
Answer

The issue isn't that title is undefined — it's that without withDefaults, optional props are undefined at runtime when not passed. But if the parent passes :title="hello", title is 'hello'. The real bug is the assumption that "optional" means "has a default" — it doesn't. Optional means "can be absent (undefined)."

If the developer expects count to default to 0 when not passed, it's actually undefined:

TypeFixed.ts
const props = defineProps<{
  title?: string
  count?: number
}>()

// ✅ Add withDefaults to provide runtime defaults
const { title = 'Default', count = 0 } = withDefaults(props, {
  title: 'Default',
  count: 0,
})
// Now: count is 0 when parent doesn't pass it (not undefined)

The lesson: optional props (?) in defineProps<{...}>() are undefined at runtime when absent — they don't have defaults. Use withDefaults() to provide runtime fallback values.