DynamoDB schema validation with dynotable-orm
How do you validate DynamoDB items against a schema?
DynamoDB enforces only the key schema of a table and its indexes, so the item schema lives in your
code. dynotable-orm validates every whole-item write against your Zod or Standard Schema before it sends
the request, and with the Zod adapter it also checks each updated path. A failed check throws a
ValidationError, and nothing is written.
This page lists what runs on each call, what the unknownKeys option does, and what changes when the
schema is not Zod. To mark keys, indexes and Date storage on a Zod schema, see the Zod adapter.
What DynamoDB checks, and what it does not
A DynamoDB table has no item schema. CreateTable declares only the key attributes and their scalar
types (S, N or B), for the table and for each index. Every other attribute can hold any
data type, and two items in one table can hold different attributes under
the same name. A typo in an attribute name writes a new attribute.
So a schema in the application is the only place that says what an item holds. dynotable-orm uses it for
three jobs: the TypeScript types, the runtime check before a write, and the conversion of values such as
Date and Set to DynamoDB's attribute values.
What is checked on each call
| call | what runs |
|---|---|
create, batchPut, transaction.create | the whole schema, refinements included, after the defaults are filled in |
update, transaction.update (Zod schema) | each $SET value and each element $ADD appends to a list, against its field's schema |
update (another Standard Schema) | nothing: Standard Schema can only validate a whole value |
get, query, scan, batchGet | nothing: a stored item is read as stored, with undeclared keys dropped and Dates converted back |
| keys and condition values (Zod schema) | the field's transforms run; a value that fails the field's checks is sent as given |
A whole-item write is checked before anything is sent. batchPut checks every item first, so one bad
item rejects the call and no item is written. The check may be async: a Zod .refine(async …) works on a
whole-item write.
A whole-item write
import {model, ValidationError} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Booking = model(
'Booking',
z
.object({
id: z.string().hashKey(),
guest: z.email(),
nights: z.number().int().min(1),
from: z.date(),
to: z.date()
})
.refine((b) => b.from < b.to, {message: 'from must be before to', path: ['to']})
);
try {
await Booking.create({
id: 'b1',
guest: 'ada@example.com',
nights: 2,
from: new Date('2026-12-03'),
to: new Date('2026-12-01')
});
} catch (e) {
if (!(e instanceof ValidationError)) throw e;
console.log(e.message); // 'from must be before to at to'
console.log(e.path); // ['to']
console.log(e.issues.length); // 1: every issue the schema reported
}e.issuesholds every issue the schema reported, in Standard Schema form (message,path).e.pathis the path of the first issue, ande.messageis its message plusat <path>.- The defaults are filled in before the check, so a
.default()value goes through the field's own checks and transforms too.
Every whole-item write runs the full schema, with its refinements and formats such as z.email() or
z.uuid(). dynamoose skipped or warned in some of these places, so old items that it stored can fail
when you write them again. Stricter checks in the migration
guide lists each case.
Updates are checked path by path
An update does not hold the whole item, so the item-level refinement above cannot run on it. With a Zod schema, each value is checked against the schema of its own field instead:
import {model, ValidationError} from 'dynotable-orm';
import {hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';
const Product = model(
'Product',
z.object({
sku: hashKey(z.string()),
name: z.string().min(1),
price: z.number().nonnegative(),
stock: z.number().int().default(0),
lines: z.array(z.object({lang: z.string().length(2), text: z.string()})).optional()
})
);
const failures: unknown[] = [
// $SET: the value is checked against `price`'s schema.
await Product.update({sku: 'p1'}, {price: -1}).catch((e) => e),
// $ADD to a list: each appended element is checked against the element schema.
await Product.update({sku: 'p1'}, {$ADD: {lines: [{lang: 'eng', text: 'Hi'}]}}).catch((e) => e),
// $REMOVE of a required field fails: the item would no longer match the schema.
await Product.update({sku: 'p1'}, {$REMOVE: ['name']}).catch((e) => e)
];
for (const e of failures) if (e instanceof ValidationError) console.log(e.message);
// A number $ADD is a delta, not a value, so `nonnegative()` does not judge it.
await Product.update({sku: 'p1'}, {$ADD: {stock: -5}});- A
$SETvalue is also transformed by its field's schema before it is stored. - A number or Set operand of
$ADDor$DELETEis a delta, so it is not checked. To keep a counter from going below zero, add a condition to the update. - A field whose parent has its own checks or transform cannot be updated alone. For example, with
period: z.object({from, to}).refine(…), an update ofperiod.fromthrows anExpressionError: write the wholeperiod, which the refinement then judges. - A refinement on the whole item never runs on an update. When a rule spans several fields, write the item
with
create(item, {overwrite: true}), or guard the update with a condition.
Read, write and update covers the update operators themselves.
Unknown keys
A key that the schema does not declare is dropped on write and on read, at every depth, except where the
schema opens an object. The unknownKeys model option chooses which schemas open one.
'zod' (the default) follows Zod's own meaning:
z.object()drops unknown keys.z.looseObject(), or an object with.catchall(), keeps them.z.record()keeps its own entries.z.strictObject()rejects them with aValidationError(Unrecognized key: "x").
'dynamoose' follows the rule of a converter from Zod to dynamoose that writes saveUnknown: a
z.record(), z.any() or z.custom() field opens its parent object at every depth below it, and a
z.looseObject() or .catchall() is closed. Use it only when you move schemas that already go through
such a converter.
import {model} from 'dynotable-orm';
import {z} from 'zod';
const shape = {
id: z.string(),
profile: z.object({name: z.string()}),
flags: z.looseObject({beta: z.boolean()}),
settings: z.object({theme: z.string(), extra: z.record(z.string(), z.string()).optional()})
};
const input = {
id: 'u1',
profile: {name: 'Ada', nickname: 'ada'},
flags: {beta: true, darkMode: true},
settings: {theme: 'light', locale: 'en'}
};
const ZodMode = model('User', z.object(shape), {hashKey: 'id'});
const DynamooseMode = model('User', z.object(shape), {hashKey: 'id', unknownKeys: 'dynamoose'});
const a = await ZodMode.create(input, {return: 'request'});
// profile: {name}, flags: {beta, darkMode}, settings: {theme}
const b = await DynamooseMode.create(input, {return: 'request'});
// profile: {name}, flags: {beta}, settings: {theme, locale}: the record opens `settings`
console.log(a.Item, b.Item);In both modes, a top-level $SET of an attribute the schema does not declare is dropped. An update that
is left with nothing to write throws an ExpressionError. To see an item as it is stored, undeclared
keys included, read it with get(key, {raw: true}). The migration
guide has the rules for $SET values and appended elements in
'dynamoose' mode.
Input types and output types
A write takes the schema's input type, and a read returns its output type. They differ wherever the schema fills in or changes a value:
- A field with
.default()is optional oncreateand always present on the item a read returns. - A field with
.transform()takes the input type on a write. The stored value is the transform's output, and nothing transforms it back on read. z.coerce.number()takes any input on a write and reads back as a number.
ItemOf<typeof Model> is the output type, the item a read returns. create, batchPut,
transaction.create and the values of update take the input type.
A whole-item write takes a Partial of the input type. So a missing required field compiles, and the
runtime check rejects it with a ValidationError. A wrong field name or a value of the wrong type does
not compile.
Standard Schema without Zod
Any library that implements Standard Schema can validate a model, such as
Valibot or ArkType. The library reads only the schema's validate function, so some features need Zod:
- Whole-item writes are validated, async validation included. That is the only check.
- Updates are not checked. Standard Schema can only validate a whole value, not one path of it.
- Keys and condition values are not transformed. They are sent as you give them, with a
Datein its stored form. - Keys, indexes and Date storage are model options, as there are no field marks.
- Reads keep every stored attribute. Nothing is dropped, because the library cannot see the declared keys.
- Dates read back as
Dateonly on the paths in thedatesoption and on the timestamps. Elsewhere, aDateis stored bydefaultDateStorageand reads back as the stored string or number. createTableInput()throws aConfigErrorfor a key that is not a Date path: it cannot tell a string key from a number key without the Zod adapter. Write theCreateTableinput yourself.
import {model, type StandardSchema} from 'dynotable-orm';
interface EventItem {
id: string;
name: string;
at: Date;
}
// A hand-written Standard Schema. A schema library gives you this object.
const eventSchema: StandardSchema<EventItem> = {
'~standard': {
version: 1,
vendor: 'example',
validate(value) {
const v = value as Partial<EventItem>;
if (typeof v.id !== 'string') return {issues: [{message: 'id is required', path: ['id']}]};
if (!v.name) return {issues: [{message: 'name is required', path: ['name']}]};
if (!(v.at instanceof Date)) return {issues: [{message: 'at must be a Date', path: ['at']}]};
return {value: v as EventItem};
}
}
};
export const Event = model('Event', eventSchema, {
hashKey: 'id',
dates: {at: 'milliseconds'} // without it, `at` reads back as the stored number
});
await Event.create({id: 'e1', name: 'Launch', at: new Date('2026-12-01T09:00:00Z')});
const event = await Event.get('e1');
console.log(event?.at instanceof Date); // trueNext steps
- The Zod adapter: field marks for keys, indexes, combined fields and Date storage.
- Troubleshooting: what each error means.
- The
ValidationErrorandModelOptionsreference.