DynamoDB with TypeScript: a complete app with dynotable-orm

How do you use DynamoDB with TypeScript?

Write the shape of your items once, as a schema, and let a library derive the TypeScript types from it. With dynotable-orm, a Zod schema gives the item type, the key type, the update paths, the condition values and the index names. A wrong field then fails at compile time, and the schema validates each item at runtime.

DynamoDB itself stores no item schema, so DescribeTable cannot give you these types. How to generate TypeScript types from DynamoDB compares the three ways to get them: hand-written interfaces, a schema-first library, or inference from real items. This page takes the schema-first way and builds one small app with it, end to end.

The app

A team task tracker with two tables:

  • Project: one item per project, with a count of its tasks.
  • Task: one item per task, keyed by project and task id, with a status, an assignee, a due date, story points and tags. A global index byAssignee lists the tasks of one person by due date.

The app does six things: create the tables, add a task (in a transaction with the project's count), read a task, start it (update operators), list a person's overdue tasks (a query on the index), and complete it only while it is open (a condition).

The whole program

Install dynotable-orm @aws-sdk/client-dynamodb zod on Node 24 or later, and run DynamoDB Local on port 8000. Then this one file runs as is:

import {CreateTableCommand, DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {
  ifNotExists,
  isConditionalCheckFailed,
  model,
  setClient,
  transaction,
  type ItemOf
} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const client = new DynamoDBClient({
  region: 'eu-west-1',
  endpoint: 'http://localhost:8000', // DynamoDB Local; remove it, and the credentials, for AWS
  credentials: {accessKeyId: 'local', secretAccessKey: 'local'}
});
setClient(client);

// 1. The models
export const Project = model(
  'Project',
  z.object({
    id: z.string().hashKey(),
    name: z.string().min(1),
    taskCount: z.number().int().default(0)
  })
);

export const Task = model(
  'Task',
  z.object({
    projectId: z.string().hashKey(),
    taskId: z.string().rangeKey(),
    title: z.string().min(1),
    status: z.enum(['open', 'done']).default('open'),
    assignee: z.string().index({name: 'byAssignee', rangeKey: 'dueAt'}),
    dueAt: z.date(),
    points: z.number().int().nonnegative().default(1),
    tags: z.set(z.string()).optional(),
    startedAt: z.date().optional(),
    createdAt: z.date().optional(),
    updatedAt: z.date().optional()
  }),
  {timestamps: true}
);

export type TaskItem = ItemOf<typeof Task>;

// 2. Create the tables (the library never creates one)
export async function createTables() {
  await client.send(new CreateTableCommand(Project.createTableInput()));
  await client.send(new CreateTableCommand(Task.createTableInput()));
}

// 3. Add a task and count it on its project, both or neither
export async function addTask(projectId: string, taskId: string, assignee: string, dueAt: Date) {
  await transaction([
    Task.transaction.create({projectId, taskId, title: `Task ${taskId}`, assignee, dueAt}),
    Project.transaction.update(
      {id: projectId},
      {$ADD: {taskCount: 1}},
      {condition: Project.condition().where('id').attributeExists()}
    )
  ]);
}

// 4. Read one task
export async function getTask(projectId: string, taskId: string): Promise<TaskItem | undefined> {
  return Task.get({projectId, taskId});
}

// 5. Start a task: update operators
export async function startTask(projectId: string, taskId: string) {
  return Task.update(
    {projectId, taskId},
    {
      $SET: {startedAt: ifNotExists(new Date())},
      $ADD: {points: 2, tags: ['in-progress']}
    }
  );
}

// 6. A person's open tasks that are past due: a query on the index
export async function overdueFor(assignee: string) {
  return Task.query('assignee')
    .eq(assignee)
    .where('dueAt')
    .lt(new Date())
    .filter('status')
    .eq('open')
    .using('byAssignee')
    .sort('ascending')
    .exec();
}

// 7. Complete a task, only while it is open: a condition
export async function completeTask(projectId: string, taskId: string): Promise<boolean> {
  try {
    await Task.update(
      {projectId, taskId},
      {status: 'done', $DELETE: {tags: ['in-progress']}},
      {condition: Task.condition().where('status').eq('open')}
    );
    return true;
  } catch (e) {
    if (isConditionalCheckFailed(e)) return false; // already done, or no such task
    throw e;
  }
}

await createTables();
await Project.create({id: 'p1', name: 'Website'});
await addTask('p1', 't1', 'ada', new Date('2026-11-01'));
await startTask('p1', 't1');

const overdue = await overdueFor('ada');
const ids = overdue.map((t) => t.taskId);
console.log(overdue.count, ids); // 1 ['t1']
console.log(await completeTask('p1', 't1')); // true
console.log(await completeTask('p1', 't1')); // false: it is no longer open
console.log((await getTask('p1', 't1'))?.status); // 'done'

The sections below go through it part by part.

1. The models and their types

A model is a name, a schema and options. The schema is plain Zod, plus the key marks from extendZod(z):

  • .hashKey() and .rangeKey() give the table's composite primary key: all tasks of a project share the hash key projectId.
  • .index({name: 'byAssignee', rangeKey: 'dueAt'}) declares a global secondary index hashed on assignee and sorted by dueAt. A local index takes type: 'local'. GSI vs LSI explains the choice.
  • .default() fills a value on create. .min(1), .int() and .nonnegative() are checked on every whole-item write.

ItemOf<typeof Task> is the item type: status is 'open' | 'done', dueAt is a Date, tags is a Set<string> | undefined. You write no interface by hand, so the type and the runtime check cannot drift apart. That drift is the problem the type-generation guide describes.

Dates are stored as epoch milliseconds by default, so dueAt sorts as a number in the index. To store ISO 8601 strings, which read better in the console, set defaultDateStorage: 'iso'.

2. Creating the tables

createTableInput() gives the CreateTable input for the model: its key schema, its indexes and their projections, with on-demand billing. Here it is sent once, for a local run. In production, define the tables in your infrastructure code.

3. A transaction

Task.transaction.create(...) and Project.transaction.update(...) do not send anything. Each builds one entry of a TransactWriteItems request, and transaction([...]) sends them together. Either both writes happen, or neither does:

  • The task's create is conditional on its key not being stored yet, as a plain create is.
  • The project's update carries a condition that the project exists. Without it, an update on a missing key creates a new item.

When an entry fails, transaction() re-throws the SDK's TransactionCanceledException unchanged, with a reasons array: one code per entry, in order ('None' for the entries that passed). A transaction holds 1 to 100 entries, and two entries on one key throw an ExpressionError before sending. DynamoDB transactions covers the costs and limits.

4. A typed get

Task.get({projectId, taskId}) resolves to TaskItem | undefined. The key type needs both key attributes. With {attributes: ['title', 'status']}, the result type narrows to those fields.

5. Update operators

An update is a key and a changes object. Plain fields are an implicit $SET. The operators are the four clauses of an update expression:

  • $SET writes values. ifNotExists(value) writes only when the attribute is not stored yet, so a second startTask keeps the first startedAt.
  • $ADD adds to a number (points: 2), adds elements to a Set (tags: ['in-progress']), or appends to a list. A number $ADD is the atomic counter pattern.
  • $DELETE takes elements out of a Set.
  • $REMOVE deletes attributes: $REMOVE: ['startedAt'].

The paths and values are typed: $ADD: {status: 1} does not compile, because status is not a number, a Set or a list. With the Zod adapter, each $SET value is also checked at runtime against its field's schema. update resolves to the new item.

6. A query on an index

Task.query('assignee').eq(assignee) starts with an equality on the index's hash key. .where('dueAt').lt(...) is a range condition on the index's sort key, so it goes into the key condition. .filter('status').eq('open') is on a non-key attribute, so it becomes a filter expression, which DynamoDB applies after it reads the items. Key condition expressions explains the difference in cost.

  • .using('byAssignee') names the index. Without it, the library picks one that fits the conditions. The index name is typed, so a misspelled one does not compile.
  • .sort('descending') reverses the order of the sort key.
  • The result is an array of items with count, lastKey, queriedCount and timesQueried. For the next page, pass lastKey to .startAt(), or call .all() to read every page. See DynamoDB pagination.

DynamoDB leaves an item out of an index when the item lacks the index's key attribute: a sparse index. Here the schema requires assignee, so every task is in it.

7. A condition

Task.condition().where('status').eq('open') is a condition expression typed from the schema: where('state') or .eq('closed') does not compile. The update happens only when the stored item matches. Otherwise the SDK throws a ConditionalCheckFailedException, and isConditionalCheckFailed(e) tells it from other errors. A missing item fails the condition too, so completeTask returns false for an unknown task.

What the compiler catches

Each line below is a compile error. @ts-expect-error marks it, so this block compiles only while the error is still caught:

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

extendZod(z);

const Task = model(
  'Task',
  z.object({
    projectId: z.string().hashKey(),
    taskId: z.string().rangeKey(),
    status: z.enum(['open', 'done']).default('open'),
    assignee: z.string().index({name: 'byAssignee'}),
    points: z.number().default(1)
  })
);

// @ts-expect-error: the key needs taskId too
await Task.get({projectId: 'p1'});

// @ts-expect-error: 'closed' is not a status
await Task.update({projectId: 'p1', taskId: 't1'}, {status: 'closed'});

// @ts-expect-error: points takes a number
await Task.update({projectId: 'p1', taskId: 't1'}, {$ADD: {points: '1'}});

// @ts-expect-error: $ADD needs a number, a Set or a list, and status is a string
await Task.update({projectId: 'p1', taskId: 't1'}, {$ADD: {status: 1}});

// @ts-expect-error: the schema has no field 'state'
Task.condition().where('state').eq('open');

// @ts-expect-error: the model has no index 'byOwner'
Task.query('assignee').eq('ada').using('byOwner');

The same checks without a library mean hand-written expression strings, placeholder maps and a cast on every read. Should you use an ORM with DynamoDB? shows that code and when it is still the better choice.

Next steps

Updated