Getting started with dynotable-orm in Node.js
How do you set up a DynamoDB ORM in Node.js?
Install dynotable-orm with the AWS SDK v3 DynamoDB client and Zod, on Node 24 or later. Give the
library a DynamoDBClient with setClient(). Define a model from a Zod schema that marks the hash key.
Create the table from createTableInput(). Then call create, get, update and delete on the
model, with types from the schema.
This page does each step once, against a local table. DynamoDB with TypeScript then builds a complete small app.
Install
npm install dynotable-orm @aws-sdk/client-dynamodb zod- Node 24 or later. The package declares
engines: {node: ">=24"}. @aws-sdk/client-dynamodbv3 is a peer dependency. dynotable-orm sends its requests through theDynamoDBClientyou give it, so your SDK version, credentials and middleware stay yours.zodv4 is an optional peer dependency. With Zod 4, the adapter reads keys, indexes and Date storage from the schema. Without it, see Standard Schema without Zod.
The package has ES module and CommonJS builds, so import and require both work.
Connect a client
setClient() sets the client that every model uses. Without it, the first call builds
new DynamoDBClient({}), which reads the region and the credentials from the environment, as the SDK
always does.
For this page, run DynamoDB Local on port 8000 and point the client at it:
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {setClient, setDefaults} from 'dynotable-orm';
setClient(
new DynamoDBClient({
region: 'eu-west-1',
endpoint: 'http://localhost:8000',
credentials: {accessKeyId: 'local', secretAccessKey: 'local'}
})
);
// Optional: every table name becomes `dev-<model name>`.
setDefaults({prefix: 'dev-'});For AWS, drop the endpoint and the credentials. A client can also be set in three narrower places:
- the
clientmodel option, for one model; - the
clientoption of aget,create,update,delete,batchGet,batchPut,batchDeleteortransaction()call, or of a query's or scan'sexec(), for one call; getClient()returns the client in use, for your own SDK commands.
Define a model
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z); // once, before you build a schema
export const Note = model(
'Note',
z.object({
ownerId: z.string().hashKey(),
noteId: z.string().rangeKey(),
title: z.string().min(1),
body: z.string().default(''),
pinned: z.boolean().default(false),
createdAt: z.date().optional(),
updatedAt: z.date().optional()
}),
{timestamps: true}
);extendZod(z)adds.hashKey(),.rangeKey(),.index(),.combine()and.storage()to Zod schemas. Each mark returns a new schema. If you would rather not change Zod's prototype, use the functions fromdynotable-orm/zodinstead:hashKey(z.string()).- The table name is the model name, with the prefix and suffix from
setDefaults()or the model'sprefixandsuffixoptions:dev-Notehere. - A model must name its hash key.
model()throws aConfigErrorwhen it has none, so a bad model definition fails when the module loads, not on the first request. timestamps: truesetscreatedAton create andupdatedAton every write. Declare both fields in the schema: the library sets their values. See timestamps.
The composite primary key here is ownerId + noteId, so
every note of one owner is in one item collection.
Create the table
dynotable-orm never creates or changes a table. createTableInput() gives the CreateTable input for
the model's keys and indexes, with on-demand billing. Send it with the SDK:
import {CreateTableCommand, DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {model} from 'dynotable-orm';
import {hashKey, rangeKey} from 'dynotable-orm/zod';
import {z} from 'zod';
const Note = model(
'Note',
z.object({ownerId: hashKey(z.string()), noteId: rangeKey(z.string()), title: z.string()})
);
const client = new DynamoDBClient({
region: 'eu-west-1',
endpoint: 'http://localhost:8000',
credentials: {accessKeyId: 'local', secretAccessKey: 'local'}
});
console.log(Note.createTableInput().KeySchema);
// [{AttributeName: 'ownerId', KeyType: 'HASH'}, {AttributeName: 'noteId', KeyType: 'RANGE'}]
await client.send(new CreateTableCommand(Note.createTableInput()));In production, own your tables in infrastructure code (CloudFormation, CDK or Terraform). The input is a quick way to make a local or test table that matches the model.
Create, get, update and delete
import {model} from 'dynotable-orm';
import {hashKey, rangeKey} from 'dynotable-orm/zod';
import {z} from 'zod';
const Note = model(
'Note',
z.object({
ownerId: hashKey(z.string()),
noteId: rangeKey(z.string()),
title: z.string().min(1),
body: z.string().default(''),
pinned: z.boolean().default(false)
})
);
// Writes only when the key is not stored yet. Resolves to the item as `get` reads it.
const created = await Note.create({ownerId: 'u1', noteId: 'n1', title: 'Groceries'});
console.log(created.body); // '' (the default)
// The item, or undefined.
const note = await Note.get({ownerId: 'u1', noteId: 'n1'});
console.log(note?.title);
// Plain fields are a $SET. Resolves to the new item (ALL_NEW by default).
const updated = await Note.update({ownerId: 'u1', noteId: 'n1'}, {pinned: true});
console.log(updated.pinned); // true
// Replace the whole item.
await Note.create({ownerId: 'u1', noteId: 'n1', title: 'Shopping'}, {overwrite: true});
await Note.delete({ownerId: 'u1', noteId: 'n1'});The key is an object with the key attributes. A model with only a hash key also takes the bare value,
such as get('u1').
When a call fails
dynotable-orm throws its own error classes before it sends anything, and re-throws SDK errors unchanged:
import {isConditionalCheckFailed, model, ValidationError} from 'dynotable-orm';
import {hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';
const Note = model('Note', z.object({noteId: hashKey(z.string()), title: z.string().min(1)}));
try {
await Note.create({noteId: 'n1', title: ''});
} catch (e) {
if (e instanceof ValidationError) console.log(e.path, e.issues); // ['title'], the Zod issues
}
await Note.create({noteId: 'n2', title: 'first'});
try {
await Note.create({noteId: 'n2', title: 'again'}); // n2 is already stored
} catch (e) {
if (!isConditionalCheckFailed(e)) throw e;
console.log('n2 exists');
}ValidationError: the schema rejected an item.issuesholds the schema's issues andpaththe path of the first one.ConfigError: a model definition that cannot work, thrown bymodel().ExpressionError: a path, update, condition, key, batch or transaction that DynamoDB would reject.MarshallError: a value that cannot be stored, such as an empty Set or a Set of mixed types.isConditionalCheckFailed(e)is true for the SDK error of a write whose condition failed. Acreateon a stored key fails that way.
The errors reference lists each one.
Dates
A z.date() field is stored as a number of epoch milliseconds by default, as in dynamoose. Set
defaultDateStorage ('milliseconds', 'seconds' or 'iso') for a whole model, the dates option for
one path, or .storage() on a field. A model that reads a table whose Dates are ISO strings or seconds
needs that setting: see the Date storage trap.
Keys and indexes as model options
Any Zod 4 object schema uses the adapter, with or without extendZod(): the marks only add the key
metadata. You can give the same settings as model options instead of marks:
import {model} from 'dynotable-orm';
import {z} from 'zod';
export const Session = model(
'Session',
z.object({userId: z.string(), startedAt: z.number(), device: z.string()}),
{
hashKey: 'userId',
rangeKey: 'startedAt',
indexes: {device: {name: 'byDevice', rangeKey: 'startedAt'}}
}
);Another Standard Schema library validates whole items in the same way, with the keys, indexes and Date storage given as options. Some features need the Zod adapter, such as updates checked path by path: Standard Schema without Zod lists each difference.
Next steps
- DynamoDB with TypeScript: a complete small app with an index query, a condition and a transaction.
- Migrating from dynamoose, if you have dynamoose code.
- The model API for every method and option.