Supabase 型別生成與進階用法 | Supabase 完整教學

2026/10/07
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 型別裡,每一張資料表都不是只有「一種型別」,而是三種,因為「讀」「寫」「改」對欄位的要求本來就不同:

型別用途欄位規則
Rowselect 讀出來的資料每個欄位都在(含資料庫自動填的 id、created_at)
Insertinsert 要寫進去的資料有預設值或可 NULL 的欄位是可選(?)
Updateupdate 要改的欄位所有欄位皆可選(你通常只改幾欄)

舉例來說,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 怎麼幫你管理登入狀態——這是幾乎每個應用程式都少不了的一塊。下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →