如何从 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 的表统计面板里:
- 打开一张表,点 Stats(统计)按钮(柱状图图标),再点 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。


