Supabase 型別生成與進階用法 | Supabase 完整教學
上一篇我們把 supabase-js 的 CRUD 讀寫玩熟了,但那些查詢的
data型別其實還很鬆散。這一篇要補上最後一塊拼圖——型別安全:用 Supabase CLI 的supabase gen types typescript從資料庫 schema 生成Database型別,搭配createClient<Database>注入 client,讓每一次select、insert、update都有完整的 TypeScript 推導與 IntelliSense。你還會學到Tables/TablesInsert等 helper、用QueryData推導複雜關聯查詢、overrideTypes覆寫,以及abortSignal、csv、explain等進階選項與 service_role 客戶端。
前言
一句話定義本篇主題:型別生成,是用 Supabase CLI 讀取你資料庫的實際結構(schema),自動產出一份對應的 TypeScript 宣告檔(通常叫 database.types.ts),再把裡面的 Database 型別注入 createClient<Database>,讓 supabase-js 的每一個查詢都能在編譯階段就知道「這張表有哪些欄位、各是什麼型別」。 換句話說,它把「你資料庫的真相」搬進了 TypeScript 的型別系統,讓 IDE 與編譯器成為你寫查詢時的第一道防線。
上一篇《CRUD 查詢與寫入》我們用 from().insert().select() 這套鏈式文法把讀寫操作補齊了,但你可能已經注意到一件事:那些 const { data } = await supabase.from('posts').select() 拿到的 data,型別其實很模糊——TypeScript 不知道 posts 表有哪些欄位,於是你打錯欄位名(post.titel)、少給必填欄位、把字串塞進數字欄位,通通要等到執行時才炸。這一篇就要把這道缺口補起來:讓這類錯誤在你打字的當下,就被 IDE 用紅波浪線攔下來。
用一個類比:沒生成型別的 supabase-js,就像拿到一把沒有標示尺寸的螺絲起子,你得憑記憶猜哪支對得上哪顆螺絲;生成型別後,等於每支起子都印上了規格,拿錯、對不上,工具箱自己就會提醒你。 而 supabase gen types typescript 就是那台「幫每支工具印上規格」的機器——它去讀資料庫這本「規格書」,把每張表、每個欄位的精確型別,一次刻進 TypeScript。
本篇你會學到:
- 型別生成流程:用
supabase gen types typescript從遠端--project-id或本地--local生成Database型別 - 注入型別:
createClient<Database>讓每個查詢自動推導Row型別、獲得 IntelliSense - 型別 helper:
Tables<'x'>/TablesInsert/TablesUpdate/Enums,以及QueryData推導複雜關聯查詢 - 進階用法:
overrideTypes覆寫、abortSignal取消請求、csv()匯出、explain()查看查詢計畫 - service_role 客戶端:伺服器端繞過 RLS 的正確配置,以及 CI 同步型別的重要性
核心概念
在敲指令之前,先把「型別生成」這件事的來龍去脈想清楚。理解了下面三點,後面的實作你會知道每一步在做什麼、為什麼要這樣做。
一、型別生成的流程:schema 是真相來源
整個型別安全機制的起點,是你 PostgreSQL 資料庫裡的 schema——每張表有哪些欄位、各是什麼型別、能不能為 NULL、有沒有預設值、外鍵關係、有哪些 Enum 與 View。這份 schema 才是「真相」。型別生成做的事,就是把這份真相「翻譯」成 TypeScript:
PostgreSQL schema(真相)
│
▼ supabase gen types typescript(CLI 讀取並翻譯)
│
database.types.ts(含 export type Database)
│
▼ import type { Database } + createClient<Database>
│
每個 supabase-js 查詢 → 自動型別推導、IntelliSense、編譯期檢查
關鍵在於這是一份快照:型別檔反映的是「你執行 gen types 那一刻」的 schema。之後只要你改動資料庫結構,這份快照就過時了,必須重新生成——這也是後面〈常見錯誤〉的重點。
二、每張表都有 Row / Insert / Update 三種型別
生成的 Database 型別裡,每一張資料表都不是只有「一種型別」,而是三種,因為「讀」「寫」「改」對欄位的要求本來就不同:
| 型別 | 用途 | 欄位規則 |
|---|---|---|
| Row | select 讀出來的資料 | 每個欄位都在(含資料庫自動填的 id、created_at) |
| Insert | insert 要寫進去的資料 | 有預設值或可 NULL 的欄位是可選(?) |
| Update | update 要改的欄位 | 所有欄位皆可選(你通常只改幾欄) |
舉例來說,posts 表的 id 是資料庫自動遞增的——所以在 Row 裡它一定存在(id: number),但在 Insert 裡它是可選的(id?: number,你不用給,資料庫會填)。這個「三型別」設計,正是為什麼 supabase-js 能精準地在 insert 時要求你給必填欄位、在 update 時又允許你只給一兩欄。
三、createClient<Database> 是注入型別的開關
生成了型別檔還不夠——你得把它「注入」client,supabase-js 才知道要用它。這個注入動作就是在 createClient 加上泛型參數:
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types' // 生成的型別檔
// 加上 <Database> 泛型,client 就綁定了你的 schema
const supabase = createClient<Database>(url, anonKey)
加了 <Database> 之後,神奇的事情發生了:supabase.from('posts').select() 的 data 會自動推斷成 posts 表的 Row[],你在 . 之後打欄位名時 IDE 會自動補全,insert 給錯欄位會編譯錯誤。不加泛型,就等於你生成了型別卻沒接上電源——client 依然對 schema 一無所知,退回無型別安全的狀態。
關鍵術語一次看懂:
Database:生成的頂層型別,結構是Database['public']['Tables']['表名']['Row' | 'Insert' | 'Update']。Tables<'x'>helper:Tables<'posts'>等同上面那串索引存取的Row,只是短很多、可讀性高。QueryData:從一個「查詢定義」反推它精確回傳型別的工具,專治手寫關聯查詢型別容易出錯的問題。- service_role key:伺服器端專用、繞過 RLS 的萬能金鑰,絕不可出現在前端。
實作範例
以下範例假設你已安裝好 supabase-js,並有一張 posts 表(欄位含 id、title、content、author_id、published、created_at)與一張 profiles 表。所有指令與程式碼皆可直接使用。
一、安裝 CLI 並生成型別
型別生成靠 Supabase CLI。用 npx 就不必全域安裝:
# 確認 CLI 可用
npx supabase --version
# 從「遠端專案」生成(PROJECT_REF 是專案的 project id,可在 Dashboard → Settings 找到)
npx supabase gen types typescript --project-id "$PROJECT_REF" > src/types/database.types.ts
# 從「本地開發環境」生成(需先 supabase start 啟動本地 Supabase)
npx supabase gen types typescript --local > src/types/database.types.ts
# 只生成特定 schema(預設是 public,可指定多個)
npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public,storage > src/types/database.types.ts
> src/types/database.types.ts 是把 CLI 印到標準輸出的型別內容,導向存成檔案。這個檔應該 commit 進版控,讓團隊與 CI 拿到同一份。
二、生成的型別長什麼樣
打開生成的 database.types.ts,你會看到類似這樣的結構(節錄),每張表都有 Row、Insert、Update:
export type Database = {
public: {
Tables: {
posts: {
Row: { // select 讀出來的完整型別
id: number
title: string
content: string | null // 可為 NULL
author_id: string
published: boolean
created_at: string
}
Insert: { // insert 用:有預設值的欄位可選
id?: number // 自動遞增,不用給
title: string // 必填
content?: string | null
author_id: string
published?: boolean // 有預設值 false,可不給
created_at?: string
}
Update: { // update 用:所有欄位皆可選
id?: number
title?: string
content?: string | null
author_id?: string
published?: boolean
created_at?: string
}
Relationships: [ /* 外鍵關係,供關聯查詢推導 */ ]
}
// profiles: { ... }
}
Views: { /* ... */ }
Functions: { /* RPC 函式的 Args 與 Returns */ }
Enums: { user_role: 'admin' | 'editor' | 'viewer' }
}
}
注意 Insert 裡 title 是必填、id 與 published 是可選——這正對應資料庫「title 是 NOT NULL、id 自動遞增、published 有預設值」的真相。
三、注入型別:createClient<Database>
把生成的 Database 型別注入 client,型別安全就正式生效:
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from '@/types/database.types'
export const supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
現在回頭看上一篇的查詢,型別全都自動推導出來了:
import { supabase } from '@/lib/supabase'
const { data } = await supabase.from('posts').select()
// data 自動推斷為 posts 的 Row[] | null——不用你手寫任何型別
// 打錯欄位名,編譯階段就被攔下
const { data: bad } = await supabase.from('posts').select('titel')
// ~~~~~ ❌ 'titel' 不存在,IDE 標紅
// insert 漏給必填欄位 title,也是編譯錯誤
await supabase.from('posts').insert({ author_id: 'user-1' })
// ~~~~~~~~~~~~~~~~~~~~~~~ ❌ 缺少必填的 title
四、型別 helper:Tables / TablesInsert / TablesUpdate
要在函式簽名裡引用某張表的型別時,別再手寫那串又長又易錯的 Database['public']['Tables']['posts']['Row']。supabase-js 提供了 helper:
import { supabase } from '@/lib/supabase'
import type { Tables, TablesInsert, TablesUpdate, Enums } from '@supabase/supabase-js'
import type { Database } from '@/types/database.types'
// 用 helper 定義型別別名(乾淨又語意清楚)
type Post = Tables<'posts'> // = Row
type PostInsert = TablesInsert<'posts'> // = Insert(插入用)
type PostUpdate = TablesUpdate<'posts'> // = Update(更新用)
type UserRole = Enums<'user_role'> // = 'admin' | 'editor' | 'viewer'
// 型別安全的新增函式:參數用 Insert、回傳用 Row
async function createPost(post: PostInsert): Promise<Post> {
const { data, error } = await supabase
.from('posts')
.insert(post)
.select()
.single()
if (error) throw error
return data
}
// 型別安全的更新函式:參數用 Update
async function updatePost(id: number, updates: PostUpdate): Promise<Post> {
const { data, error } = await supabase
.from('posts')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
}
小提醒:
Tables<'posts'>需要 client 已經注入了Database泛型才能正確解析——helper 是靠已綁定的 client 型別去查表的。
五、QueryData:推導複雜關聯查詢的型別
上一篇提過關聯查詢(select('title, author:profiles(username)'))。這種帶巢狀關聯的回傳型別很難手寫,手寫還容易跟實際查詢對不上。QueryData 專治這個——它從「查詢本身」反推精確型別:
import { supabase } from '@/lib/supabase'
import type { QueryData } from '@supabase/supabase-js'
// 1. 先把查詢定義成一個變數(別立刻 await)
const postsWithAuthorQuery = supabase
.from('posts')
.select(`
id,
title,
author:profiles ( id, username, avatar_url )
`)
// 2. 用 QueryData 從查詢推導回傳型別——含巢狀的 author,完全不用手寫
type PostWithAuthor = QueryData<typeof postsWithAuthorQuery>
async function getPosts(): Promise<PostWithAuthor> {
const { data, error } = await postsWithAuthorQuery
if (error) throw error
return data // 型別精準吻合 select 的欄位結構
}
重點是:PostWithAuthor 會跟著 select 字串自動變化——你改了 select 選的欄位,型別自動同步,不必手動維護一份平行的 interface。
六、進階選項:abortSignal、csv、explain、overrideTypes
supabase-js 的查詢鏈還有幾個實用的進階方法:
// abortSignal:可取消的請求(例如搜尋框 debounce、元件卸載時中止)
const controller = new AbortController()
const promise = supabase
.from('posts')
.select()
.abortSignal(controller.signal)
setTimeout(() => controller.abort(), 3000) // 3 秒後自動取消
// csv():把查詢結果直接以 CSV 字串回傳(適合匯出、下載報表)
const { data: csv } = await supabase.from('posts').select('id, title').csv()
// csv 是純文字:"id,title\n1,Hello\n2,World"
// explain():查看 PostgreSQL 查詢計畫,用於除錯慢查詢(需在專案開啟此功能)
const { data: plan } = await supabase
.from('posts')
.select()
.eq('published', true)
.explain({ analyze: true, verbose: true })
// overrideTypes:推斷型別不夠精確時,手動覆寫回傳型別
const { data } = await supabase
.from('raw_events')
.select()
.overrideTypes<{ id: string; payload: MyCustomType }[]>()
overrideTypes 是最後手段——當生成的型別對某個 JSON 欄位、或某個 RPC 回傳推得太寬鬆(例如 Json)時,你可以用它精確指定。但別濫用:能靠重新生成型別解決的,就別用手動覆寫掩蓋問題。
七、service_role 客戶端(伺服器端專用)
前面的 client 都用 anon key(受 RLS 保護,適合前端)。但在伺服器端(API route、Edge Function、排程工作),你有時需要繞過 RLS 做管理操作——這時用 service_role key:
import { createClient } from '@supabase/supabase-js'
import type { Database } from '@/types/database.types'
// ⚠️ service_role key 繞過所有 RLS,擁有完整權限——絕對只能放在伺服器端,永不進前端 bundle
export const supabaseAdmin = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // 不是 NEXT_PUBLIC_,不會被打包進前端
{
auth: {
autoRefreshToken: false, // 伺服器端不需自動刷新 token
persistSession: false, // 也不需持久化 session
},
}
)
注意兩個關鍵配置:autoRefreshToken: false 與 persistSession: false——因為 service_role 客戶端沒有「使用者 session」的概念,關掉這兩者才對。而 key 的環境變數名刻意不加 NEXT_PUBLIC_ 前綴,確保它只留在伺服器、不會被打包進送到瀏覽器的程式碼。注入 <Database> 泛型的好處在這裡同樣成立:即使是管理端操作,你依然享有完整型別安全。
常見錯誤與最佳實踐
坑一:改了 schema 卻沒重新生成型別,TypeScript 給你「錯誤的安全感」。
這是型別生成最隱蔽的坑。你在資料庫加了一個 NOT NULL 的新欄位、或把某欄位型別從 text 改成 int,但沒重跑 supabase gen types——於是 database.types.ts 還是舊快照。編譯完全通過(因為型別檔沒變),你以為安全,執行時卻被資料庫以 not_null_violation(23502)或型別不符打回。正確做法:把型別生成當成 migration 流程的一環——改完 schema 就順手重生型別,並把重生的型別檔一起 commit。型別檔過時比沒有型別更危險,因為它會騙你。
坑二:用了 any 或不注入泛型,白白繞過型別系統。
有人嫌型別報錯麻煩,就把 client 宣告成 any、或乾脆不加 <Database> 泛型——這等於生成了型別卻不用,退回無型別安全狀態。正確做法:一律用 createClient<Database>;遇到型別對不上時,先想「是不是 schema 改了該重生型別」或「是不是該用 QueryData / overrideTypes」,而不是一個 any 蓋過去。any 會像傳染病一樣把型別安全從那個點開始瓦解。
坑三:CI/團隊之間型別檔不同步。
A 改了 schema 並在本機重生型別、但忘了 commit 型別檔;B 拉下來的還是舊型別,寫查詢時 IDE 給的是過時提示,兩人對 schema 的「認知」出現分歧。正確做法:把型別檔納入版控,並在 CI 加一個「重生型別並比對是否有 diff」的檢查——若 CI 生成的型別跟 repo 裡的不一致,就讓 build 失敗,強制提醒有人漏 commit。可在 package.json 加腳本方便執行:
{
"scripts": {
"types:generate": "supabase gen types typescript --project-id $PROJECT_REF > src/types/database.types.ts",
"types:local": "supabase gen types typescript --local > src/types/database.types.ts"
}
}
坑四:把 service_role key 放進前端環境變數。
用了 NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY 這種命名,或在客戶端元件 import 了 supabaseAdmin——service_role 繞過 RLS,一旦洩漏到瀏覽器,等於把整個資料庫的後門交出去。正確做法:service_role key 只用不帶公開前綴的環境變數(如 SUPABASE_SERVICE_ROLE_KEY),且只在伺服器端程式碼(API route、Edge Function、後端服務)import,永不出現在會被打包進前端 bundle 的檔案裡。
最佳實踐總結:
- 改 schema 就重生型別:把
gen types綁進 migration 流程,並 commit 型別檔 - 一律
createClient<Database>:不注入泛型 = 白生成;拒絕用any逃避 - CI 比對型別 diff:防止團隊之間型別檔不同步
- helper 取代長索引:用
Tables/TablesInsert/TablesUpdate/Enums - 複雜查詢用
QueryData:讓型別跟著select自動同步,別手寫 - service_role 只在伺服器:不帶公開前綴、關閉
autoRefreshToken與persistSession
小結
這是 Supabase 系列教學的第 018 篇,也是「SB-2 存取層」這一段的收尾。延續上一篇《CRUD 查詢與寫入》——我們把 insert/select/update/upsert/delete 這套讀寫文法補齊之後,這一篇把最後一塊拼圖「型別安全」裝上:用 Supabase CLI 從資料庫 schema 生成 Database 型別、以 createClient<Database> 注入,讓每個查詢都在編譯期就有完整推導與 IntelliSense。
核心觀念濃縮成一句話:用 supabase gen types typescript --project-id(或 --local)從 schema 生成 database.types.ts,createClient<Database> 注入型別後,select 自動推導 Row、insert/update 比對 Insert/Update;引用型別用 Tables / TablesInsert / TablesUpdate / Enums helper,複雜關聯查詢用 QueryData 推導,必要時以 overrideTypes 覆寫——而最關鍵的紀律是:改了 schema 就重生型別、並讓 CI 同步,否則過時的型別會給你錯誤的安全感。
- schema 是真相:型別是它的快照,改了就得重生
createClient<Database>是開關:不注入泛型 = 白生成- 三型別對應讀寫:
Row讀、Insert寫、Update改 - helper 與
QueryData:少手寫、讓型別跟著查詢走 - service_role 只在伺服器:繞過 RLS,永不進前端
至此「SB-2 存取層」告一段落——你已經能安全、型別完整地讀寫 Supabase 資料庫了。接下來我們要進入 Supabase 的核心服務。下一篇《Auth 入門》,會從最基礎的使用者驗證開始:註冊、登入、session 是什麼、supabase-js 怎麼幫你管理登入狀態——這是幾乎每個應用程式都少不了的一塊。下一篇見。