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 do pick, partial, extend and omit on the object.
  • extendZod refuses a conflict. If ZodType already has a different method with one of these names, for example from another extension, it throws a ConfigError and 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:

propertywhat it does
namethe 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
rangeKeyfor a global index, the field it is sorted by
projecttrue (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 role throws an ExpressionError, because the stored orgId is 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 ConfigError at model().
  • 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 a ConfigError instead.
  • 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

Zodin 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())); // undefined

index is always an array in the metadata, even when the mark was given one definition.

Next steps

Updated