Intermediate6 min read

How to Generate TypeScript Types from a DynamoDB Table

In Postgres you'd introspect information_schema and generate types from it. DynamoDB has no equivalent — and it's not a missing feature, it's the data model: DynamoDB stores no item schema at all. The only attributes the service knows about are the ones used in keys. DescribeTable's AttributeDefinitions is explicit about its own scope: each entry "describes one attribute in the table and index key schema" (AWS API reference) — your table's other fifty attributes simply aren't recorded anywhere.

So "generate TypeScript types from DynamoDB" always means one of three things: declare the shape yourself, derive it from a schema you author in code, or infer it from the items that actually exist.

How do I get TypeScript types for a DynamoDB table?

There's no API that returns a table's item shape — DescribeTable only knows the key attributes. Your options: hand-write an interface and validate at the boundary (a Zod schema makes the types and the runtime check one artifact), use a schema-first library where the schema you author yields the types, or infer the shape from real items — by script, or with a tool like DynoTable that scans the table and exports a TypeScript interface, Zod schema, or JSON Schema.

Method 1: hand-write the interface + validate at the boundary

The AWS SDK can't type your items for you. The v3 Document client returns items as untyped records — every GetCommand / QueryCommand result is effectively Record<string, unknown> until you assert otherwise. A bare as Order cast compiles fine and lies at runtime, which is why the robust version pairs the interface with a runtime check:

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

One schema, two jobs: z.infer gives you the static type, parse catches the item that doesn't match it — which in a schemaless store is a when, not an if. The catch is equally plain: the schema documents your intent, not your table. Nothing stops an old writer from having stored total as a string, and hand-written types drift silently as the data evolves.

If you're working from raw (non-Document-client) API output, remember the wire shape is type-tagged DynamoDB-JSON ({"S": "..."}, {"N": "123"}) — see marshalling, and use the DynamoDB JSON converter to flip a sample between wire and plain form while you write the schema.

Method 2: schema-first libraries

Toolkits like ElectroDB and DynamoDB-Toolbox attack the drift problem from the write side: you author an entity schema in code, and the library derives the TypeScript types and enforces the shape on every read and write it performs. That's the strongest guarantee available — but note the direction: you write the schema; the library doesn't discover it. Pointing one at an existing table still means reverse-engineering the item shapes yourself first, and items written outside the library are outside its guarantees. They shine on greenfield single-table designs where every entity goes through the toolkit from day one.

Method 3: infer the types from real items

For an existing table, the ground truth is the data. Scan a sample, union the shapes:

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

Real-world pitfalls the naive version hits immediately:

  • Sparse attributes. DynamoDB items in one table can have different attributes; an attribute present on 80% of items is optional, not missing. Track per-attribute frequency, not just presence.
  • Mixed entities. In a single-table design, USER# and ORDER# items share the table — one merged interface for both is useless. Partition the sample by the type attribute and emit one type per entity.
  • Type collisions. The same attribute stored as N here and S there is a real (and common) data bug — surface it as a union rather than silently picking one. The full tag set is in data types.
  • A sample is a sample. Attributes that only appear on rare items may not be in your first 5,000 — and the scan costs read capacity either way (query vs scan).

One-click inference in DynoTable

That inference script — sampling, frequency tracking, nested paths, the per-entity split — is built into DynoTable's Table stats panel:

  1. Open a table, hit the Stats button (the bar-chart icon), and click Index table. DynoTable scans the table once with live progress and discovers every attribute your items carry — including nested ones by dotted path, like commonData.status — with its type and whether it was required or optional across the rows scanned.
  2. Click Export and pick a format:
    • TypeScript — an interface.
    • Zod — a z.object(...) schema (Standard-Schema compatible).
    • JSON Schema — draft 2020-12.
  3. Copy it to the clipboard or save it to a file.
DynoTable's Table stats panel: the indexed-field list with types and required/optional flags, and the schema Export button.
DynoTable's Table stats panel: the indexed-field list with types and required/optional flags, and the schema Export button.

The export is honest about what it is: every generated schema opens with a note that it was inferred from the sampled items — a strong starting point, not an authoritative contract. Optionality reflects how often each attribute appeared while indexing, and primary-key attributes are always marked required. Indexing incurs normal DynamoDB read costs, and Reindex refreshes the picture after your data changes.

FAQ

Can I generate types from DescribeTable? Only for the key attributes. AttributeDefinitions covers the table and index key schema — nothing else about your items is stored by the service, so there is no server-side schema to introspect.

What's the best way to type an existing production table? Infer first, then harden: sample the real items (script or DynoTable's indexed export) to get the actual shape, review it, and promote it to a hand-owned Zod schema or a schema-first library entity so future drift is caught at the boundary.

How do I handle multiple entity types in one table? One type per entity, never one merged type. Split the sample on your type attribute (or key prefix) and generate a separate interface for each — the discriminated union of those is your table type.

Why do my generated types say a required field is optional? Because some sampled item didn't have it. In a schemaless store optionality is an observation, not a declaration — check whether those items are legacy rows to backfill (see migrations) or a genuinely optional attribute.

Do the types cover DynamoDB sets and binary? A converter has to choose plain-JSON representations: sets become arrays and binary becomes an encoded string — the same mapping quirks covered in marshalling. Round-trip a sample through the DynamoDB JSON converter to see exactly what your attributes look like on each side.

Stop guessing your table's shape — download DynoTable, index the table, and export a TypeScript, Zod, or JSON Schema in one click.

Updated