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 indexbyAssigneelists 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 keyprojectId..index({name: 'byAssignee', rangeKey: 'dueAt'})declares a global secondary index hashed onassigneeand sorted bydueAt. A local index takestype: '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
createis. - 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:
$SETwrites values.ifNotExists(value)writes only when the attribute is not stored yet, so a secondstartTaskkeeps the firststartedAt.$ADDadds to a number (points: 2), adds elements to a Set (tags: ['in-progress']), or appends to a list. A number$ADDis the atomic counter pattern.$DELETEtakes elements out of a Set.$REMOVEdeletes 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,queriedCountandtimesQueried. For the next page, passlastKeyto.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
- Getting started for the client options and the error classes.
- The model API and queries and conditions for every method.
- To check the items the app stored, open the
Tasktable in DynoTable, a desktop DynamoDB GUI.