型別安全出問題時,症狀通常不是「型別錯了」,而是型別根本沒在保護你——編譯全綠、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 | 整段沒被檢查,卻看起來有 |
| 過期的型別快照 | 欄位存在與否與型別相反 |
所以判準不是「型別寫得夠不夠仔細」,而是這個型別是從哪裡來的,以及還有沒有別的地方在描述同一件事。只有一個來源時,型別檢查才真的是檢查。