中階閱讀時間 3 分鐘

如何從 DynamoDB 資料表產生 TypeScript 型別

在 Postgres 裡你會內省 information_schema 並從它產生型別。 DynamoDB 沒有對應物——而且這不是缺功能,這就是它的資料 模型:DynamoDB 完全不儲存項目的 schema。 服務唯一知道的 屬性,是用在索引鍵裡的那些。DescribeTableAttributeDefinitions 對自己的範圍講得很明白:每個條目「描述 資料表與索引鍵 schema 中的一個屬性」 (AWS API 參考)—— 你資料表裡其他五十個屬性根本沒有被記錄在任何地方。

所以「從 DynamoDB 產生 TypeScript 型別」永遠是三件事之一: 自己宣告形狀、從你在程式碼中撰寫的 schema 推導出它,或 從實際存在的項目推斷出它。

我要如何為 DynamoDB 資料表取得 TypeScript 型別?

沒有任何 API 會回傳資料表的項目形狀——DescribeTable 只知道 索引鍵屬性。你的選項:手寫一個 interface 並在邊界驗證 (一個 Zod schema 讓型別和執行期檢查合為一件 成品)、用一個 schema 優先的程式庫(你撰寫的 schema 產出 型別),或從真實項目推斷形狀——用腳本,或用像 DynoTable 這樣的工具掃描資料表並匯出 TypeScript 介面、Zod schema 或 JSON Schema。

方法 1:手寫介面+在邊界驗證

AWS SDK 沒辦法替你的項目定型別。v3 的 Document client 把項目 以無型別的記錄回傳——每個 GetCommand / QueryCommand 的結果在 另行斷言之前,實際上都是 Record<string, unknown>。一個光禿禿的 as Order 轉型能順利編譯卻在執行期說謊,這就是為什麼健全的 版本把介面和一個執行期檢查配成對:

import {z} from 'zod';

const Order = z.object({
  PK: z.string(), // ORDER#<id>
  SK: z.string(), // META
  status: z.enum(['open', 'shipped', 'cancelled']),
  total: z.number(),
  couponCode: z.string().optional() // sparse attribute
});
type Order = z.infer<typeof Order>;

const {Item} = await doc.send(new GetCommand({TableName: 'Orders', Key: key}));
const order = Order.parse(Item); // typed AND verified

一份 schema,兩份工作:z.infer 給你靜態型別,parse 抓到 不符合它的項目——在一個無 schema 的儲存裡,那是 遲早 的事, 不是 會不會 的事。缺點也同樣直白:這份 schema 記錄的是你的 意圖,不是你的資料表。沒有什麼能阻止某個舊的寫入端曾把 total 存成字串,而手寫的型別會隨著資料演進悄悄漂移。

如果你處理的是原始(非 Document-client)的 API 輸出,記得傳輸 形狀是帶型別標籤的 DynamoDB-JSON({"S": "..."}{"N": "123"})——見 marshalling,並在撰寫 schema 時用 DynamoDB JSON 轉換器把一份樣本 在傳輸形式與純 JSON 之間來回翻轉。

方法 2:schema 優先的程式庫

ElectroDBDynamoDB-Toolbox 這樣的工具組從寫入端 進攻漂移問題:你在程式碼中撰寫一份實體 schema,程式庫推導出 TypeScript 型別,並且 在它執行的每一次讀寫上強制那個形狀。 那是可取得的最強保證——但注意方向:schema 是你寫的; 程式庫不會替你發現它。 把它指向一張既有的資料表,仍然意味著 你得先自己逆向工程出那些項目形狀,而在程式庫之外寫入的項目 不在它的保證範圍內。它們在綠地的單一資料表設計 上最出色——每個實體從第一天起就走工具組。

方法 3:從真實項目推斷型別

對一張既有的資料表,資料才是基準真相。掃描一份樣本, 把形狀取聯集:

const seen = new Map<string, Set<string>>(); // attr -> observed types
let count = 0;
let key: Record<string, unknown> | undefined;
do {
  const page = await doc.send(new ScanCommand({TableName: 'Orders', ExclusiveStartKey: key}));
  for (const item of page.Items ?? []) {
    count++;
    for (const [attr, value] of Object.entries(item)) {
      const t = Array.isArray(value) ? 'array' : typeof value;
      (seen.get(attr) ?? seen.set(attr, new Set()).get(attr)!).add(t);
    }
  }
  key = page.LastEvaluatedKey;
} while (key && count < 5000);
// emit: attribute -> type union, optional if seen in < count items

天真版本馬上會撞到的真實世界陷阱:

  • 稀疏屬性。 同一張 DynamoDB 資料表裡的項目可以有不同的 屬性;一個出現在 80% 項目上的屬性是 optional,不是 不存在。要追蹤逐屬性的出現頻率,不只是有沒有出現過。
  • 混合實體。單一資料表設計中, USER#ORDER# 項目共用一張資料表——把兩者合併成一個介面 毫無用處。按型別屬性把樣本 分割,為每個實體各產出一個型別。
  • 型別衝突。 同一個屬性這裡存成 N、那裡存成 S,是一個 真實(而且常見)的資料 bug——把它呈現為聯集,而不是悄悄 挑一個。完整的標籤集在資料型別
  • 樣本終究是樣本。 只出現在罕見項目上的屬性可能不在你的前 5,000 筆裡——而且無論如何那次掃描都要花讀取容量 (query vs scan)。

DynoTable 中的一鍵推斷

那個推斷腳本——取樣、頻率追蹤、巢狀路徑、逐實體 分割——內建在 DynoTableTable stats 面板裡:

  1. 打開一張資料表,按 Stats 按鈕(長條圖圖示),點 Index table。DynoTable 會帶著即時進度把資料表掃描一次,並 找出你的項目帶有的每一個屬性——包含以點路徑表示的巢狀 屬性,像 commonData.status——連同它的型別,以及它在被掃描的 列中是必填還是選填
  2. Export 並挑一種格式:
    • TypeScript——一個 interface
    • Zod——一個 z.object(...) schema(相容 Standard-Schema)。
    • JSON Schema——draft 2020-12。
  3. 複製到剪貼簿或存成檔案。
DynoTable 的資料表統計面板:帶型別與必填/選填旗標的已建索引欄位清單,以及 schema 的 Export 按鈕。
DynoTable 的資料表統計面板:帶型別與必填/選填旗標的已建索引欄位清單,以及 schema 的 Export 按鈕。

這份匯出對自己是什麼很誠實:每一份產生的 schema 開頭都有一段 註記,說明它是從被取樣的項目推斷出來的——一個很好的起點, 不是一份權威的契約。選填與否反映每個屬性在建索引期間出現的 頻率,而主索引鍵屬性永遠標為必填。建索引會產生一般的 DynamoDB 讀取成本,而 Reindex 會在你的資料變更後刷新這幅 圖像。

常見問答

我可以從 DescribeTable 產生型別嗎? 只有索引鍵屬性可以。AttributeDefinitions 涵蓋資料表與 索引鍵 schema——關於你項目的其他一切服務都沒有儲存, 所以不存在可以內省的伺服器端 schema。

替一張既有的生產資料表定型別,最好的方式是什麼? 先推斷,再加固:取樣真實項目(腳本或 DynoTable 的索引式匯出)取得實際形狀,檢視它, 然後把它升格成一份自己維護的 Zod schema 或 schema 優先程式庫的 實體,讓未來的漂移在邊界被抓到。

同一張資料表裡有多種實體型別怎麼辦? 一個實體一個型別,永遠不要一個合併的型別。按你的 型別屬性(或索引鍵前綴)分割樣本, 為每一個產生獨立的介面——它們的可辨識聯集就是你的 資料表型別。

為什麼我產生的型別把一個必填欄位標成選填? 因為某個被取樣的項目沒有它。在無 schema 的儲存裡,選填與否 是一項觀察,不是一項宣告——檢查那些項目是要回填的舊 列(見遷移),還是一個真正 選填的屬性。

這些型別涵蓋 DynamoDB 的 set 與 binary 嗎? 轉換器必須選擇純 JSON 的表示法:set 變成陣列、binary 變成 編碼過的字串——和 marshalling 講的那些對應怪癖相同。把一份樣本 用 DynamoDB JSON 轉換器來回轉一遍,就能看到你的 屬性在兩邊各長什麼樣。

別再用猜的猜你資料表的形狀——下載 DynoTable,把資料表 建好索引,一鍵匯出 TypeScript、Zod 或 JSON Schema。

已更新