跳至主要內容
Skip to content

Vue 與 TypeScript 實戰

Vue + TypeScript 的重點不是把所有地方都加上型別,而是讓元件 API、資料流、Composable、Store、API 回應都能被約束。好的型別設計會讓錯誤提早在開發階段出現,壞的型別設計則會讓專案充滿 any 和錯誤的安全感。

一句話回答:Vue + TypeScript 實戰要先把元件邊界型別定清楚:Props、Emits、v-model、Template Ref、Composable 回傳值、API response、Pinia store 都要有明確型別;同時要避免濫用 any、過度相信後端資料,以及用型別斷言掩蓋真實的 null/undefined 狀態。


一、 必考觀念

1. TypeScript 解決什麼問題?

在 Vue 專案裡,TypeScript 最有價值的地方通常是邊界:

  • 父元件傳 props 給子元件。
  • 子元件 emit 事件給父元件。
  • v-model 傳值與更新事件。
  • Composable 的輸入輸出。
  • API response。
  • Pinia store。
  • Template ref。
  • 第三方套件封裝。

這些地方如果沒有型別,很容易變成「傳錯欄位、emit 錯 payload、API 回來資料不符合預期」。

2. 型別不是 runtime 驗證

這點非常重要。

typescript
type User = {
  id: string
  name: string
}

const user = await fetchUser() as User

這只是在告訴 TypeScript:「相信我,它是 User。」

但如果後端真的回:

json
{
  "id": 123,
  "nickname": "Ada"
}

TypeScript 不會在 runtime 幫你擋。

面試時可以說:

TypeScript 能約束開發時的型別,但不能保證 API runtime 資料一定正確。重要資料仍然需要後端契約、runtime schema validation,或至少在 API layer 做資料轉換。

3. 不要用 any 把問題蓋掉

any 會讓 TypeScript 放棄檢查。

typescript
const user: any = await fetchUser()

user.profile.name.firstName

這看起來很自由,但實際上和沒用 TypeScript 差不多。

更好的做法是:

typescript
type User = {
  id: string
  name: string
  email?: string
}

const user = ref<User | null>(null)

讓 null、optional、不同狀態都被誠實表達。


二、 Props 型別

1. defineProps 基本寫法

vue
<script setup lang="ts">
type Props = {
  title: string
  count?: number
  disabled?: boolean
}

const props = defineProps<Props>()
</script>

這樣父層如果少傳必填 props,或傳錯型別,TypeScript 會提示。

2. withDefaults

如果 props 有預設值:

vue
<script setup lang="ts">
type Props = {
  size?: 'small' | 'medium' | 'large'
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  size: 'medium',
  disabled: false,
})
</script>

這比在元件內到處寫 fallback 更清楚。

3. Props 解構要小心

很多人會寫:

typescript
const { title } = defineProps<{
  title: string
}>()

現代 Vue 對 <script setup> 的 reactive props destructure 有編譯支援,但面試時仍要知道:props 本身是響應式來源,解構行為要看 Vue 版本與編譯能力。

保守寫法:

typescript
const props = defineProps<{
  title: string
}>()

const displayTitle = computed(() => props.title.trim())

如果團隊成員對版本差異不熟,保守寫法更容易理解。


三、 Emits 型別

1. defineEmits 約束事件名稱與 payload

vue
<script setup lang="ts">
const emit = defineEmits<{
  save: [payload: UserForm]
  cancel: []
  'update:modelValue': [value: string]
}>()

function submit() {
  emit('save', {
    name: 'Ada',
    email: 'ada@example.com',
  })
}
</script>

這樣可以檢查:

  • event name 是否正確。
  • payload 是否符合。
  • 沒有 payload 的事件是否誤傳參數。

2. 不要讓 emit payload 變成 unknown blob

不理想:

typescript
const emit = defineEmits<{
  change: [value: any]
}>()

更好:

typescript
type ChangePayload = {
  id: string
  checked: boolean
}

const emit = defineEmits<{
  change: [payload: ChangePayload]
}>()

事件 payload 是元件 API 的一部分,應該清楚穩定。

延伸讀:Vue 元件通訊方式有哪些及原理


四、 v-modeldefineModel

1. 傳統 v-model 型別

子元件:

vue
<script setup lang="ts">
const props = defineProps<{
  modelValue: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function update(value: string) {
  emit('update:modelValue', value)
}
</script>

父元件:

vue
<BaseInput v-model="keyword" />

2. defineModel

Vue 3.4+ 可以使用 defineModel 簡化:

vue
<script setup lang="ts">
const model = defineModel<string>({
  required: true,
})
</script>

使用時:

vue
<input v-model="model" />

多個 model:

typescript
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')

延伸讀:v-model 雙向綁定的原理

3. 表單型別要區分 Draft 和 DTO

表單草稿不一定等於 API payload。

typescript
type UserForm = {
  name: string
  email: string
  roleIds: string[]
}

type UpdateUserPayload = {
  name: string
  email: string
  roles: string[]
}

送出前轉換:

typescript
function toPayload(form: UserForm): UpdateUserPayload {
  return {
    name: form.name.trim(),
    email: form.email.trim(),
    roles: form.roleIds,
  }
}

延伸讀:Vue 表單、非同步狀態與資料流設計


五、 Template Ref 型別

1. DOM ref

vue
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)

function focus() {
  inputRef.value?.focus()
}
</script>

<template>
  <input ref="inputRef" />
</template>

一定要包含 null,因為元件 mount 前 ref 還沒有值,v-if false 時也可能是 null。

延伸讀:Vue 中 ref 的作用

2. Component ref

如果要拿子元件暴露的方法:

子元件:

vue
<script setup lang="ts">
function reset() {
  // reset internal state
}

defineExpose({
  reset,
})
</script>

父元件:

typescript
import UserForm from './UserForm.vue'

const formRef = ref<InstanceType<typeof UserForm> | null>(null)

function resetForm() {
  formRef.value?.reset()
}

注意:Component ref 是命令式 API,適合 focus、reset、scrollTo 這類場景,不適合一般資料同步。


六、 Composable 泛型

1. useRequest 泛型

typescript
export function useRequest<TData>(
  request: () => Promise<TData>
) {
  const data = ref<TData | null>(null)
  const loading = ref(false)
  const error = ref<unknown>(null)

  async function execute() {
    loading.value = true
    error.value = null

    try {
      data.value = await request()
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  return {
    data,
    loading,
    error,
    execute,
  }
}

使用:

typescript
const { data: user, execute } = useRequest<User>(() => {
  return fetchUser(userId.value)
})

這樣 user 的型別會是 Ref<User | null>

2. useSelection 泛型

typescript
export function useSelection<T extends string | number>() {
  const selected = ref<Set<T>>(new Set())

  function toggle(id: T) {
    if (selected.value.has(id)) {
      selected.value.delete(id)
      return
    }

    selected.value.add(id)
  }

  function clear() {
    selected.value.clear()
  }

  return {
    selected,
    toggle,
    clear,
  }
}

使用:

typescript
const { selected, toggle } = useSelection<string>()

toggle('user-1')

泛型能讓 Composable 保持通用,同時不犧牲型別。

延伸讀:Composition API 實戰與 Composable 設計


七、 API Response 型別

1. API layer 要定義 DTO

typescript
type UserDto = {
  id: string
  name: string
  email: string | null
}

type User = {
  id: string
  displayName: string
  email: string
}

不要讓後端 DTO 到處流進 UI。

可以在 API layer 轉換:

typescript
function toUser(dto: UserDto): User {
  return {
    id: dto.id,
    displayName: dto.name,
    email: dto.email ?? '',
  }
}

這樣 UI 面對的是穩定的前端模型。

2. 不要過度相信 API 型別

就算你寫了:

typescript
async function fetchUser(): Promise<UserDto> {
  const response = await fetch('/api/user')
  return response.json()
}

這也只是 TypeScript 層面的承諾。

如果是高風險資料,可以用 runtime schema validation,例如在 API layer 驗證後再回傳。

3. Error 型別也要設計

不要只丟 unknown 到畫面。

可以在 API client 統一轉換:

typescript
type ApiError = {
  message: string
  code?: string
  fields?: Record<string, string>
}

表單頁就能根據 fields 回填欄位錯誤。


八、 Pinia Store 型別

1. Setup Store 的型別通常自然推導

typescript
export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)

  const isLogin = computed(() => Boolean(user.value && token.value))

  function logout() {
    user.value = null
    token.value = null
  }

  return {
    user,
    token,
    isLogin,
    logout,
  }
})

Pinia 會根據回傳值推導 store 型別。

2. Option Store 要注意 state 型別

typescript
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    loading: false,
  }),
  actions: {
    async fetchUser(id: string) {
      this.loading = true

      try {
        this.user = await fetchUserApi(id)
      } finally {
        this.loading = false
      }
    },
  },
})

如果直接寫 user: null,TypeScript 可能推導得太窄,所以要明確告訴它 User | null

延伸讀:Vue 狀態管理:Pinia、Vuex 與狀態歸屬


九、 Slot Props 型別

1. Slot props 也是元件 API

在 Vue 3.3+ 可以用 defineSlots 描述 slot 型別:

vue
<script setup lang="ts">
type Row = {
  id: string
  name: string
}

defineSlots<{
  default(props: { row: Row }): unknown
  empty(): unknown
}>()
</script>

這能讓使用者在寫 slot 時得到更好的提示。

2. 不要暴露不穩定內部細節

Slot props 一旦被父層使用,就變成公開 API。

vue
<template #default="{ row, selected, toggle }">

rowselectedtoggle 都應該是穩定且有語意的型別。

延伸讀:Vue 插槽設計:Slot、Scoped Slot 與 Headless Component


十、 常見陷阱

1. ref(null) 推導太窄

不理想:

typescript
const user = ref(null)

這會讓 user 的型別太窄。

更好:

typescript
const user = ref<User | null>(null)

2. 濫用 as

typescript
const user = response as User

如果你只是為了讓錯誤消失,這很危險。

型別斷言應該用在你真的知道型別比 TypeScript 更精準的地方,而不是逃避檢查。

3. 把 optional 當必填

typescript
type User = {
  profile?: {
    avatar: string
  }
}

user.profile.avatar

應該處理 undefined:

typescript
user.profile?.avatar

或在資料轉換層補上預設值。

4. 後端 DTO 直接進 UI

後端欄位常會變,或有 null、snake_case、狀態碼。

UI 更適合面對穩定模型。

text
API DTO
-> API layer transform
-> frontend model
-> component

5. 為了型別犧牲可讀性

有些型別可以很精細,但如果團隊看不懂,維護成本也高。

資深工程師要能判斷:這裡需要精準型別,還是簡單清楚的型別就夠。


十一、 追問題庫

Q1:Vue 裡 props 怎麼寫 TypeScript?

使用 defineProps<Props>(),有預設值時搭配 withDefaults

如果 props 是公開元件 API,建議抽出明確的 Props type。

Q2:emit 怎麼約束 payload?

使用 defineEmits 的物件 tuple 型別:

typescript
const emit = defineEmits<{
  save: [payload: UserForm]
  cancel: []
}>()

Q3:Template ref 為什麼要加 null?

因為 ref 在 mount 前還不存在,條件渲染時也可能被設回 null。型別應該誠實表達生命週期。

Q4:API 回傳已經有 TypeScript 型別,還要驗證嗎?

TypeScript 不會驗證 runtime 資料。如果資料重要,仍要依靠後端契約、API layer 轉換或 runtime schema validation。

Q5:什麼時候可以用 any

可以用在非常靠近外部邊界、且短暫過渡的地方,但應該盡快收斂成 unknown、明確型別或 validated data。

大範圍使用 any 會讓 TypeScript 失去價值。


十二、 實作題

題目:設計一個型別安全的 UserForm

需求:

  • props 接收 modelValue
  • emit update:modelValuesubmit
  • 表單型別和 API payload 分離。
  • template ref 可以 focus email input。

可以這樣設計:

vue
<script setup lang="ts">
type UserForm = {
  name: string
  email: string
  roleIds: string[]
}

type UpdateUserPayload = {
  name: string
  email: string
  roles: string[]
}

const props = defineProps<{
  modelValue: UserForm
  submitting?: boolean
}>()

const emit = defineEmits<{
  'update:modelValue': [value: UserForm]
  submit: [payload: UpdateUserPayload]
}>()

const emailRef = ref<HTMLInputElement | null>(null)

function updateField<K extends keyof UserForm>(
  key: K,
  value: UserForm[K]
) {
  emit('update:modelValue', {
    ...props.modelValue,
    [key]: value,
  })
}

function toPayload(form: UserForm): UpdateUserPayload {
  return {
    name: form.name.trim(),
    email: form.email.trim(),
    roles: form.roleIds,
  }
}

function submit() {
  emit('submit', toPayload(props.modelValue))
}

function focusEmail() {
  emailRef.value?.focus()
}

defineExpose({
  focusEmail,
})
</script>

面試時可以補充:

  • updateField 用泛型約束 key 和 value 的對應關係。
  • UserForm 是 UI draft,UpdateUserPayload 是 API payload。
  • emailRef 需要 HTMLInputElement | null
  • 暴露給父層的方法要少而穩定。

十三、 資深視角

1. 型別設計是系統邊界設計

Vue + TypeScript 最值得投入的是邊界:

  • 元件 API。
  • API client。
  • Store。
  • Composable。
  • 路由 meta。
  • 表單 payload。

這些地方型別清楚,系統會穩很多。

2. 型別不能取代資料治理

如果後端資料不穩定,只靠 as User 沒用。

成熟專案會建立:

  • API DTO。
  • transform layer。
  • runtime validation。
  • error format。
  • shared schema 或 contract。

TypeScript 是防線之一,不是唯一防線。

3. 不要追求炫技型別

有些型別技巧很漂亮,但團隊維護不了。

好型別應該:

  • 能說明資料意圖。
  • 能擋住常見錯誤。
  • 不讓使用者難以理解。
  • 不需要大量 as 才能使用。

總結

問題回答重點
Props 型別defineProps<Props>(),預設值用 withDefaults
Emits 型別defineEmits 約束事件名稱與 payload
v-model 型別傳統 modelValue + update:modelValue,或 defineModel
Template RefDOM ref / Component ref 都要處理 null
Composable 泛型讓可復用邏輯保留型別安全
API 型別DTO 和前端 model 分離,不盲信 runtime data
Pinia 型別Setup Store 自然推導,Option Store 注意 state 初始值

一句完整的面試回答可以是:

Vue + TypeScript 我會先把元件邊界型別定清楚:props 用 defineProps,預設值用 withDefaults,emit 用 defineEmits 約束事件名稱和 payload,v-model 可以用 modelValue / update:modelValuedefineModel。Template ref 要誠實處理 null,Component ref 可以用 InstanceType<typeof Component>。Composable 可以用泛型保留輸入輸出型別,Pinia Setup Store 通常能自然推導。API 型別要區分後端 DTO 和前端 model,不要只用 as 相信後端資料;重要資料要在 API layer 轉換或驗證。TypeScript 的價值是讓錯誤在開發階段暴露,而不是用 any 把問題藏起來。

延伸閱讀