Intermediate6 min read

Generate TypeScript Types from DynamoDB

In Postgres you'd introspect information_schema and generate types from it. DynamoDB has no equivalent: 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 a strict 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 dialog:

  1. Open a table, hit the Settings button in the tab toolbar, pick Indexing, and click Index table. DynoTable samples the table with live progress and records the attributes it finds — including nested ones by dotted path, like commonData.status — with each one's type and whether it was required or optional across the rows scanned. The scan is capped, so an attribute that only appears in rare items can be missing; see Table overview and indexing.
  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 Indexing section: the indexed-field list with types and required/optional flags, and the schema Export button.
DynoTable's Indexing section: the indexed-field list with types and required/optional flags, and the schema Export button.

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