TypeScript 類型安全實戰

端到端型別安全的關鍵不是寫更多 interface,而是讓每一種型別只有一個來源。 這篇記三個讓型別失效的方式:型別與真實資料不符、泛型漏傳導致整條查詢變成 any、 以及 migration 後沒重新產生。

系列・Nuxt 4 全棧實戰筆記・第 3/12 篇

型別安全出問題時,症狀通常不是「型別錯了」,而是型別根本沒在保護你——編譯全綠、IDE 有提示、執行時炸掉。

會走到這一步,幾乎都是因為同一件事:某一種型別有了第二個來源。以下三個是最常見的三種分叉方式。

一、手寫的型別會跟資料庫分叉

資料庫的結構是既成事實,手寫的 interface 只是對它的一份猜測。兩者一開始會相符,然後在某一次 migration 之後不再相符——而沒有任何機制會告訴你這件事發生了。

典型的爆法是 nullable:

interface User {
  id: string
  name: string // 資料庫其實允許 null
}

const user = await fetchUser()
user.name.toUpperCase() // TypeError: Cannot read property 'toUpperCase' of null

編譯器沒有錯——它只是忠實地相信了你寫的東西。

所以資料庫型別一律用產生的,不手寫:

supabase gen types typescript --local | tee app/types/database.types.ts > /dev/null

需要單一表的型別時從產生的檔案取,不要另外複製一份:

type User = Database['public']['Tables']['users']['Row']
type UserInsert = Database['public']['Tables']['users']['Insert']
type UserRole = Database['public']['Enums']['user_role']

Row / Insert / Update 三者不同不是多餘的設計:Insert 裡有預設值的欄位是可選的,Update 全部可選。手寫一份 User 拿來當三種用途,會在「新增時漏欄位」與「更新時被迫塞全部欄位」之間來回打補丁。

二、泛型漏傳,整條查詢靜默變成 any

這是三個裡最安靜的一個:

const client = useSupabaseClient() // 漏了泛型
const { data } = await client.from('users').select('*')
// data: any —— 後面怎麼寫都不會被檢查

它不會報錯,因為 any 在型別系統裡是合法的。你會得到一個看起來有型別安全、實際上整段沒被檢查的資料流,而且愈往下游走愈難發現——中間經過幾個 composable 之後,any 已經被當成具體型別在用了。

const client = useSupabaseClient<Database>()

伺服器端同理,建立 client 的地方就要帶上泛型,之後所有查詢才承接得到:

import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import type { Database } from '~~/app/types/database.types'

export function getServerSupabaseClient(): SupabaseClient<Database> {
  // …
}

判定方式很簡單:在查詢結果上按 IDE 的型別提示,看到 any 就是漏了。這件事值得在 code review 時當成固定的一眼——它不會被 tsc 抓出來。

三、Migration 之後沒有重新產生

前兩點都做對了,還有這一個:型別檔是某個時間點的快照。資料庫改了、快照沒重跑,兩者就再次分叉,而且分叉的方向最刁鑽——新增的欄位在型別裡不存在,你用它會被擋;被刪掉的欄位在型別裡還在,你用它會編譯通過然後在執行時拿到 undefined。

解法不是「記得要跑」,是把它綁進同一條指令,讓忘記變成不可能:

supabase db reset
supabase db lint --level warning
supabase gen types typescript --local | tee app/types/database.types.ts > /dev/null
pnpm typecheck

順序有意義:先重建、先 lint,再產生型別,最後用 typecheck 驗證新型別沒有打破既有程式碼。中間任何一步失敗都應該讓整條停下來,而不是留下一個半新半舊的型別檔。

你可能會想:既然是產生出來的,那乾脆不要進版控?進。它是產生物沒錯,但它同時是你唯一看得見型別漂移的地方——進了版控,某次 migration 改掉了什麼欄位,會直接以 diff 的形式出現在 code review 裡。不進版控的話,那個變更就只存在於某個人的機器上。

前後端共用的那一份,用 schema 當來源

同樣的原則延伸到請求驗證。表單在瀏覽器端驗一次、API 在伺服器端驗一次,如果各寫各的,兩份規則遲早會漂——而且漂掉時沒有錯誤,只有「前端說可以、後端說不行」的客訴。

用一份 schema 同時當驗證器與型別來源:

const createUserSchema = z.object({
  email: z.string().email('請輸入有效的 Email'),
  name: z.string().min(2, '名稱至少 2 個字元'),
  role: z.enum(['admin', 'manager', 'staff']),
})

type CreateUserRequest = z.infer<typeof createUserSchema>

這份 schema 放在前後端都拿得到的地方(我們放 shared/),兩邊 import 同一個檔案。型別不是另外寫的,是從驗證規則推導出來的——所以它們不可能不一致。

收束

三個問題的形狀是同一個:某種型別有了第二個來源。

第二個來源症狀
手寫的 interface執行時 null 炸掉
漏傳泛型後的 any整段沒被檢查,卻看起來有
過期的型別快照欄位存在與否與型別相反

所以判準不是「型別寫得夠不夠仔細」,而是這個型別是從哪裡來的,以及還有沒有別的地方在描述同一件事。只有一個來源時,型別檢查才真的是檢查。