DynamoDB keys and secondary indexes with dynotable-orm

How do you define DynamoDB keys and indexes in TypeScript?

Mark the key fields of the schema with .hashKey() and .rangeKey(), and each indexed field with .index(), or give the same settings as model options. dynotable-orm types every key you pass from these marks, picks the index a query can use, checks index names at compile time, and builds the CreateTable input with createTableInput().

A model maps to one table, named after the model. This page covers the primary key, the secondary indexes, how a query picks one, and the table definition. The Zod adapter covers the marks themselves.

The primary key

A table's primary key is a hash key alone, or a hash key and a range key: a composite primary key. Every call that names one item takes the key as an object with the key attributes. A model with only a hash key also takes the bare value:

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

export const User = model('User', z.object({id: z.string().hashKey(), name: z.string()}));

export const Message = model(
  'Message',
  z.object({
    roomId: z.string().hashKey(),
    sentAt: z.date().rangeKey(),
    author: z.string(),
    text: z.string()
  })
);

await User.get('u1'); // a bare key: the model has only a hash key
await User.get({id: 'u1'}); // the same key as an object

const sentAt = new Date('2026-11-06T09:30:00Z');
await Message.get({roomId: 'general', sentAt}); // a composite key needs both attributes

console.log(User.hashKey, Message.rangeKey, Message.tableName); // 'id' 'sentAt' 'Message'
  • The key type comes from the schema. Message.get({roomId: 'general'}) does not compile: the range key is missing. A value of the wrong type does not compile either.
  • A key goes through the field's schema. With the Zod adapter, each key attribute runs through its field's transforms, then the whole-item set hook (the model's set option, a function over the whole item) runs once on the whole key. A Date takes its stored form, here a number of epoch milliseconds. Pass {rawKey: true} to get, update or delete to skip the transforms and the hook.
  • A missing key attribute throws. A key without one of its attributes throws an ExpressionError (the key of 'Message' has no 'sentAt') before sending, also when the types were bypassed.
  • A model must name its hash key. model() throws a ConfigError when no mark and no option names one. dynamoose took the first attribute by default; dynotable-orm does not guess.
  • A key field must be declared. With the Zod adapter, a key that the schema does not declare throws a ConfigError at model(), because Zod would strip it from every item.

M.hashKey and M.rangeKey hold the attribute names, and M.tableName the table name. The table name is ${prefix}${name}${suffix}, read on every call, so a later setDefaults({prefix}) applies to models that already exist. A model's own prefix and suffix options override the defaults.

Secondary indexes

.index() on a field, or the indexes model option, declares a secondary index. Without a type, an index is global and hashed on the field. With type: 'local', it is a local index: the table's hash key, with the field as its range key.

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

export const Order = model(
  'Order',
  z.object({
    customerId: z.string().hashKey(),
    orderId: z.string().rangeKey(),
    // A global index, sorted by placedAt, that projects only the keys and `total`.
    status: z
      .enum(['open', 'paid'])
      .index({name: 'byStatus', rangeKey: 'placedAt', project: ['total']}),
    // A local index: same hash key as the table, sorted by placedAt.
    placedAt: z.date().index({name: 'byPlacedAt', type: 'local'}),
    // A sparse global index: only orders with a couponCode are in it.
    couponCode: z.string().optional().index({name: 'byCoupon'}),
    total: z.number(),
    note: z.string().optional()
  })
);

const open = await Order.query('status').eq('open').using('byStatus').exec();
console.log(open[0]?.total); // projected

// @ts-expect-error: byStatus does not project `note`
console.log(open[0]?.note);
settingdefaultwhat it does
name<field>GlobalIndex, <field>LocalIndexthe index name. Typed: using() takes only a declared name
type'global''local' needs a table range key, or model() throws a ConfigError
rangeKeynonethe sort key of a global index. A local index is always sorted by its own field
projecttruetrue is ALL, false is KEYS_ONLY, an array is INCLUDE with those attributes

The projection is in the types too. using('byStatus') narrows each item to the table keys, the index keys and the projected attributes, so a field the index does not hold does not compile. Index projections explains the cost trade-off.

An item without the index's key attribute is left out of the index by DynamoDB: a sparse index. An optional indexed field, as couponCode above, gives one.

Several indexes on one field take an array: .index([{name: 'byEmail'}, {name: 'byEmailLocal', type: 'local'}]). GSI vs LSI compares the two kinds; a local index can only be created with the table, so decide early.

Which index a query uses

query() needs an equality on a hash key: the table's or an index's. Without using(), the library picks the index from the conditions:

  1. It takes every index whose hash key has an equality in the conditions, global indexes first, then local ones, then the table.
  2. It picks the first of those whose range key is also in the conditions.
  3. Else the table, if the table's hash key has an equality.
  4. Else the first one.
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Order = model(
  'Order',
  z.object({
    customerId: z.string().hashKey(),
    orderId: z.string().rangeKey(),
    status: z.enum(['open', 'paid']).index({name: 'byStatus', rangeKey: 'placedAt'}),
    placedAt: z.date().index({name: 'byPlacedAt', type: 'local'})
  })
);

await Order.query('customerId').eq('c1').exec(); // the table
await Order.query('status').eq('open').exec(); // byStatus: its hash key has the equality
await Order.query('customerId').eq('c1').where('placedAt').gt(new Date('2026-11-01')).exec(); // byPlacedAt
await Order.query('customerId').eq('c1').where('status').eq('open').exec(); // the table, status is a filter
await Order.query('status').eq('open').using('byStatus').exec(); // named: no choice made

The fourth query has equalities on both customerId and status. No range key is in the conditions, so the table wins, and status becomes a filter expression. Name the index with using() whenever the choice matters. A query with no equality on any hash key throws an ExpressionError (no index fits the query). Query and scan covers the key condition and the filter.

Creating the table: createTableInput()

dynotable-orm never creates or changes a table. createTableInput() returns the CreateTable input that matches the model: its key schema, its indexes and their projections, with on-demand billing. Send it with the SDK, for a local or test table:

import {CreateTableCommand, DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Order = model(
  'Order',
  z.object({
    customerId: z.string().hashKey(),
    orderId: z.string().rangeKey(),
    status: z
      .enum(['open', 'paid'])
      .index({name: 'byStatus', rangeKey: 'placedAt', project: false}),
    placedAt: z.date(),
    total: z.number()
  })
);

const input = Order.createTableInput();
console.log(input.AttributeDefinitions);
// [{AttributeName: 'customerId', AttributeType: 'S'}, {AttributeName: 'orderId', AttributeType: 'S'},
//  {AttributeName: 'status', AttributeType: 'S'}, {AttributeName: 'placedAt', AttributeType: 'S'}]
console.log(input.GlobalSecondaryIndexes?.[0]?.Projection); // {ProjectionType: 'KEYS_ONLY'}

const client = new DynamoDBClient({
  region: 'eu-west-1',
  endpoint: 'http://localhost:8000',
  credentials: {accessKeyId: 'local', secretAccessKey: 'local'}
});
await client.send(new CreateTableCommand(input));
  • Only key attributes are defined. DynamoDB needs AttributeDefinitions for the table and index keys alone, so total is not listed.
  • The attribute type comes from the schema. A string or enum field is S and a number field N. A Date key follows its storage: S for 'iso', N for 'milliseconds' or 'seconds'.
  • A type it cannot tell throws. A key field that is not one string or number type, such as a union of a string and a number, throws a ConfigError. So does every non-Date key of a schema that is not Zod: write that CreateTable input yourself.
  • The billing mode is PAY_PER_REQUEST. For provisioned capacity, change the input before you send it.

In production, own the tables in infrastructure code (CloudFormation, CDK or Terraform), and use createTableInput() as a check that the model and the table agree.

A sort key from several fields

A composite sort key such as 2026-11#paid comes from a combined field: combine(z.string(), ['month', 'status'], '#').rangeKey(). The library builds it on every whole-item write. Because it is the range key, an update cannot change its sources: an update that writes month or status throws an ExpressionError. Keep those fields out of the key, or move the item to its new key.

A new sort key is a new item. create(item, {overwrite: true}) writes the item at the new key and leaves the old item in the table. Delete the old key in the same transaction, so one write does not happen without the other:

import {model, transaction} from 'dynotable-orm';
import {combine, extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Order = model(
  'Order',
  z.object({
    customerId: z.string().hashKey(),
    month: z.string(),
    status: z.enum(['open', 'paid']),
    monthStatus: combine(z.string(), ['month', 'status'], '#').rangeKey(),
    total: z.number()
  })
);

export async function markPaid(customerId: string, month: string) {
  const order = await Order.get({customerId, monthStatus: `${month}#open`});
  if (!order) throw new Error('no open order');
  await transaction([
    Order.transaction.delete({customerId, monthStatus: order.monthStatus}),
    // Conditional on the new key not being stored yet.
    Order.transaction.create({...order, status: 'paid'})
  ]);
}

The library builds monthStatus again from the new status, so 2026-11#open becomes 2026-11#paid. The Zod adapter has the combine() rules.

One model per table

A model names its own table, and there is no entity type attribute. To keep several entity types in one table with single-table design, use a library built for it: ElectroDB or DynamoDB-Toolbox. Should you use an ORM with DynamoDB? compares them.

Next steps

Updated