Zod and DynamoDB: the dynotable-orm Zod adapter
How do you use Zod with DynamoDB?
Define the item as a Zod 4 object schema and pass it to model(). dynotable-orm takes the item type from
it and validates every write with it. The dynotable-orm/zod adapter adds what Zod has no words for: the
hash key, the range key, indexes, combined fields and Date storage, as .hashKey()-style methods or as
plain functions.
Any Zod 4 object schema uses the adapter, with or without the marks. This page covers the marks, how they relate to the model options, and what each Zod feature does to a stored item. Schema validation covers when the schema runs.
Methods or functions
extendZod(z) adds the marks as methods on Zod schemas. Call it once, before you build a schema:
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(),
status: z.enum(['open', 'paid', 'shipped']).index({name: 'byStatus', rangeKey: 'placedAt'}),
placedAt: z.date(),
total: z.number().nonnegative()
})
);If you would rather not change Zod's prototype, import the same marks as functions. Importing
dynotable-orm/zod changes nothing until you call extendZod:
import {model} from 'dynotable-orm';
import {hashKey, index, rangeKey} from 'dynotable-orm/zod';
import {z} from 'zod';
export const Order = model(
'Order',
z.object({
customerId: hashKey(z.string()),
orderId: rangeKey(z.string()),
status: index(z.enum(['open', 'paid', 'shipped']), {name: 'byStatus', rangeKey: 'placedAt'}),
placedAt: z.date(),
total: z.number().nonnegative()
})
);Both forms give the same metadata. A few rules hold for both:
- A mark returns a new schema. It clones the field and never changes the schema you pass, so one base schema can be marked in two ways.
- A mark survives the rest of the chain.
.min(),.optional(),.default(),.describe(),.transform()and.pipe()keep it, and so dopick,partial,extendandomiton the object. extendZodrefuses a conflict. IfZodTypealready has a different method with one of these names, for example from another extension, it throws aConfigErrorand adds nothing. A second call with this library's methods does nothing.
The marks
hashKey() and rangeKey()
The table's primary key: a hash key alone, or a hash key and a
range key. A model must name its hash key, by a mark or by the hashKey option, or model() throws a
ConfigError. Two hash keys, from two marks or a mark and an option that differ, throw too.
A key field must be a top-level field of the object. A mark on a nested field, or below a list, throws a
ConfigError at model().
index()
A secondary index on the field. The definition takes:
| property | what it does |
|---|---|
name | the index name; the default is <field>GlobalIndex or <field>LocalIndex |
type | 'global' (the default) hashes the index on this field; 'local' uses it as the index range key |
rangeKey | for a global index, the field it is sorted by |
project | true (the default) projects all attributes, false only the keys, an array the listed attributes |
Pass an array to put several indexes on one field: .index([{name: 'byEmail'}, {name: 'byEmailLocal', type: 'local'}]). A local index needs a table range key. Keys and indexes has the
full rules, and GSI vs LSI explains the choice.
combine()
A string field built from other fields on every whole-item write, as dynamoose's COMBINE type does:
import {model} from 'dynotable-orm';
import {combine, hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';
export const Member = model(
'Member',
z.object({
id: hashKey(z.string()),
orgId: z.string(),
role: z.enum(['admin', 'member']),
orgRole: combine(z.string(), ['orgId', 'role'], '#')
})
);
const member = await Member.create({id: 'm1', orgId: 'o1', role: 'admin'});
console.log(member.orgRole); // 'o1#admin'- The separator defaults to
,, as in dynamoose. - The value is computed before validation, so the target's own schema checks it. A value you give for the target is replaced.
- An update must write all of the sources or none of them. One that writes only
rolethrows anExpressionError, because the storedorgIdis not known. - When the target is the table's hash or range key, an update cannot change its sources at all.
A combined field is the usual way to build a composite sort key from typed fields.
storage()
How one Date field is stored: 'milliseconds' (the default), 'seconds' or 'iso'. It also works inside a list, a
record value or a union option. Dates and timestamps has the rules.
Marks or model options
Every mark has a model option with the same meaning: hashKey, rangeKey, indexes, combine and
dates. Use whichever reads better; a model can mix them.
import {model} from 'dynotable-orm';
import {z} from 'zod';
export const Order = model(
'Order',
z.object({
customerId: z.string(),
orderId: z.string(),
status: z.enum(['open', 'paid', 'shipped']),
placedAt: z.date(),
total: z.number().nonnegative()
}),
{
hashKey: 'customerId',
rangeKey: 'orderId',
indexes: {status: {name: 'byStatus', rangeKey: 'placedAt'}},
dates: {placedAt: 'iso'}
}
);- A setting that a mark and an option both give must agree. Different values throw a
ConfigErroratmodel(). - A field that an option names must be declared in the schema. Zod strips undeclared keys, so a key or an
index on one would be lost:
model()throws aConfigErrorinstead. - With a schema that is not Zod, the options are the only way. See Standard Schema without Zod.
What Zod features do to a stored item
| Zod | in DynamoDB |
|---|---|
z.string(), z.number(), z.boolean() | S, N, BOOL |
z.object(), z.record() | a map (M) |
z.array(), z.tuple() | a list (L) |
z.set(z.string()), z.set(z.number()) | a string or number Set (SS, NS). A write takes a Set, not an array |
z.date() | a string or a number, by its storage |
.nullable() | NULL for a null |
.default() | filled in on a whole-item write, nested ones too. A null takes the default where the field accepts null |
.transform() | runs on writes, keys and condition values. The stored value is its output; reads do not reverse it |
.refine(), .check() | run on every whole-item write. Async ones work there, but not on a key or condition value |
z.strictObject() | rejects undeclared keys with a ValidationError |
A transform on a key field runs on every key you pass. With email: z.string().toLowerCase().hashKey(),
get('ADA@example.com') reads the item stored as ada@example.com. Condition and query values go
through the field's transform in the same way. A key or condition value whose field has an async check or
transform throws a ConfigError: those values are parsed synchronously.
A schema from z.compile() (Zod 4.5 or later) works as a model schema and keeps its marks. Compile the
object after you mark its fields, because a mark returns a new schema that is not compiled:
import {model} from 'dynotable-orm';
import {hashKey, storage} from 'dynotable-orm/zod';
import {z} from 'zod';
export const Event = model(
'Event',
z.compile(
z.object({
id: hashKey(z.string()),
at: storage(z.date(), 'seconds')
})
)
);Types from the schema
The marks are tracked in the types too, so the model knows its keys and index names without a type parameter:
import {model, type ItemOf, type KeyInputOf} 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'}),
total: z.number()
})
);
type OrderItem = ItemOf<typeof Order>; // {customerId: string; orderId: string; status: 'open' | 'paid'; total: number}
type OrderKey = KeyInputOf<typeof Order>; // {customerId: string; orderId: string}
const key: OrderKey = {customerId: 'c1', orderId: 'o1'};
const order: OrderItem | undefined = await Order.get(key);
console.log(order?.status);
// @ts-expect-error: the model has no index 'byTotal'
Order.query('status').eq('open').using('byTotal');
// @ts-expect-error: storage() takes a Date field
z.string().storage('seconds');
// @ts-expect-error: combine() makes a string field
z.number().combine(['customerId', 'orderId']);Reading the marks: getDynamoMeta()
getDynamoMeta(schema) returns a field's marks, for tools that build on your schemas. It reads through
the wrappers a mark survives:
import {getDynamoMeta, hashKey, index} from 'dynotable-orm/zod';
import {z} from 'zod';
const email = index(hashKey(z.email()), {name: 'byEmail'}).optional();
console.log(getDynamoMeta(email)); // {hashKey: true, index: [{name: 'byEmail'}]}
console.log(getDynamoMeta(z.string())); // undefinedindex is always an array in the metadata, even when the mark was given one definition.
Next steps
- Keys and indexes: key input, index selection and
createTableInput(). - Dates and timestamps: the
storage()modes in depth. - The Zod adapter reference:
extendZod, each mark andFieldMeta.