從 DynamoDB 生成 TypeScript 型別
在 Postgres 裡,你會內省 information_schema 並據此生成型別。DynamoDB 沒有對應物——而且這不是缺失的功能,這就是它的資料模型:DynamoDB 完全不儲存項的 schema。服務唯一知道的屬性,是那些用在鍵裡的屬性。DescribeTable 的 AttributeDefinitions 對自己的範圍說得很明白:每個條目"描述表和索引鍵 schema 中的一個屬性"(AWS API 參考)——你表裡其餘的五十個屬性根本沒有被記錄在任何地方。
所以"從 DynamoDB 生成 TypeScript 型別"永遠意味著三件事之一:自己宣告形狀,從你在程式碼裡編寫的 schema 推導它,或者從實際存在的項中推斷它。
如何為一張 DynamoDB 表拿到 TypeScript 型別?
沒有任何 API 會返回一張表的項形狀——DescribeTable 只知道鍵屬性。你的選項:手寫一個 interface 並在邊界處校驗(一個 Zod schema 讓型別和執行時檢查合為一件工件)、用一個 schema 優先的庫(你編寫的 schema 產出型別),或者從真實的項推斷形狀——用指令碼,或者用像 DynoTable 這樣能掃描表並匯出 TypeScript interface、Zod schema 或 JSON Schema 的工具。
方法 1:手寫 interface + 在邊界處校驗
AWS SDK 無法替你給項定型。v3 Document 用戶端返回的是無型別的記錄——每個 GetCommand / QueryCommand 的結果實際上都是 Record<string, unknown>,直到_你_另行斷言。一個光禿禿的 as Order 斷言能順利編譯,卻在執行時撒謊,這正是穩健版本把 interface 與執行時檢查配對的原因:
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 用戶端)API 輸出,記住線上格式是帶型別標籤的 DynamoDB-JSON({"S": "..."}、{"N": "123"})——參見 marshalling,並在編寫 schema 時用 DynamoDB JSON 轉換器把一份樣本線上上格式和純 JSON 之間來回翻轉。
方法 2:schema 優先的庫
像 ElectroDB 和 DynamoDB-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#項共享一張表——給兩者合併出一個 interface 毫無用處。按型別屬性切分樣本,為每種實體產出一個型別。 - 型別衝突。同一個屬性這裡存成
N、那裡存成S,是真實(且常見)的資料 bug——把它作為聯合型別暴露出來,而不是悄悄挑一個。完整的標籤集在資料型別。 - 樣本終歸是樣本。只出現在罕見項上的屬性可能不在你的前 5,000 個裡——而且無論如何這次掃描都要花讀取容量(Query 與 Scan)。
DynoTable 中的一鍵推斷
那個推斷指令碼——抽樣、頻率跟蹤、巢狀路徑、按實體切分——已經內建在 DynoTable 的資料表對話方塊裡:
- 開啟一張表,點標籤頁工具欄的 設定 按鈕,在 索引 區段點 Index table(索引表)。DynoTable 對錶做帶實時進度的抽樣,並記錄它所發現的屬性——包括按點路徑表示的巢狀屬性,如
commonData.status——連同各自的型別,以及在掃描過的行中它是必需還是可選。該掃描有上限,因此只出現在稀有項中的屬性可能會缺失;見表總覽與索引。 - 點 Export(匯出)並選一種格式:
- TypeScript——一個
interface。 - Zod——一個
z.object(...)schema(相容 Standard Schema)。 - JSON Schema——draft 2020-12。
- TypeScript——一個
- 複製到剪貼簿,或儲存為檔案。

這個匯出對自己的身份很誠實:每份生成的 schema 開頭都有一條註釋,說明它是從被抽樣的項推斷而來——一個很強的起點,而不是權威契約。可選性反映的是索引期間各屬性出現的頻率,主索引鍵屬性始終標記為必需。索引會產生正常的 DynamoDB 讀取費用,而 Reindex(重建索引)會在資料變化後重新整理這幅圖景。
常見問題
能從 DescribeTable 生成型別嗎?
只能覆蓋鍵屬性。AttributeDefinitions 只涵蓋表和索引的鍵 schema——關於你的項,服務沒有儲存任何其他資訊,所以不存在可內省的伺服器端 schema。
給一張既有生產表定型的最佳方式是什麼? 先推斷,再固化:對真實的項抽樣(指令碼或 DynoTable 的索引匯出)拿到實際形狀,審閱它,然後把它升格為一份由你掌管的 Zod schema 或某個 schema 優先庫的實體,讓未來的漂移在邊界處被抓住。
同一張表裡有多種實體型別怎麼辦? 每種實體一個型別,絕不合併成一個。按你的型別屬性(或鍵字首)切分樣本,為每種實體生成單獨的 interface——它們的可辨識聯合(discriminated union)就是你的表型別。
為什麼生成的型別把一個必填欄位標成了可選? 因為某個被抽樣的項沒有它。在無 schema 的儲存裡,可選性是一種觀察,不是一種宣告——檢查那些項是需要回填的歷史行(見遷移),還是一個真正可選的屬性。
這些型別覆蓋 DynamoDB 的 set 和二進位制嗎? 轉換器必須選擇純 JSON 表示:set 變成陣列,二進位制變成編碼後的字串——與 marshalling 裡講的對映怪癖相同。把一份樣本在 DynamoDB JSON 轉換器裡走一個來回,就能看到你的屬性在兩側各長什麼樣。
別再猜你表的形狀了——下載 DynoTable,為表建索引,一鍵匯出 TypeScript、Zod 或 JSON Schema。


