DynamoDB condition expressions in TypeScript with dynotable-orm
How do you write a DynamoDB condition expression in TypeScript?
Build it with the model's typed chain, Model.condition().where('status').eq('open'), and pass it as the
condition option of create, update or delete. dynotable-orm checks the attribute names and value
types at compile time and renders the ConditionExpression with its placeholders. A failed condition
throws, and isConditionalCheckFailed(e) recognizes it.
A condition expression makes a write happen only when the stored item matches. DynamoDB evaluates it on the item at the moment of the write, so there is no gap between a read and the write for another process to change the item.
Build a condition
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Task = model(
'Task',
z.object({
id: z.string().hashKey(),
status: z.enum(['open', 'done', 'archived']),
points: z.number(),
tags: z.set(z.string()).optional(),
owner: z.string().optional()
})
);
const condition = Task.condition()
.where('status')
.eq('open')
.group((c) => c.where('points').gt(3).or().where('tags').contains('urgent'));
const request = await Task.update({id: 't1'}, {status: 'done'}, {condition, return: 'request'});
console.log(request.ConditionExpression); // '#a0 = :v0 AND (#a1 > :v1 OR contains (#a2, :v2))'M.condition() returns a Condition typed from the schema: where() takes only a declared attribute
(or a path below an object or a list), and each comparison takes a value of that attribute's type.
where('state') or .eq('closed') above does not compile. The chain changes the condition in place and
returns it, as in dynamoose. filter() and attribute() are other names for where().
The comparisons
| method | renders | notes |
|---|---|---|
eq(v), ne(v) | p = v, p <> v | |
lt(v), le(v), gt(v), ge(v) | p < v, p <= v, p > v, p >= v | |
between(low, high) | p BETWEEN low AND high | both ends included |
beginsWith(s) | begins_with (p, s) | a string attribute only |
contains(v) | contains (p, v) | a substring, or an element of a Set or a list |
in([a, b]) | p IN (a, b) | a non-empty array |
exists(), attributeExists() | attribute_exists (p) | |
attributeNotExists() | attribute_not_exists (p) |
The attribute names and values always go through placeholders
(#a0, :v0), so a reserved word such as status needs no special handling.
and, or, not and groups
- Comparisons join with AND.
and()is for readability only. or()starts an alternative. AND binds tighter than OR, as in DynamoDB, soa.and().b.or().cmeans(a AND b) OR c.group()adds parentheses. It takes a function that builds the inner condition, or aCondition.parenthesis()is another name for it.not()negates the next comparison:where('status').not().eq('archived')renders<>, andnot().contains()rendersNOT contains. Call it afterwhere(). Anot()beforewhere(), or at the end of the chain, throws anExpressionError: dynamoose 4.2.0 drops it there and sends the condition without the negation.beginsWith,inandbetweencannot follownot(), which throws anExpressionErrortoo.
Where a condition goes
create(item, {condition}),update(key, changes, {condition})anddelete(key, {condition}).- The same option on
M.transaction.create,updateanddelete, andM.transaction.condition(key, condition)for a check without a write. See transactions. - A
createwithout{overwrite: true}already carriesattribute_not_existson the hash key. Your condition is joined with it by AND.
Batches take no condition: DynamoDB's BatchWriteItem has none.
Condition values
- Dates take their stored form.
where('dueAt').lt(new Date())sends a number of epoch milliseconds by default, or an ISO string for a field stored as'iso'. A Date field also takes its stored form directly, such as an epoch number. - Field transforms run. With the Zod adapter, a value goes through its field's transform, so with
owner: z.string().toLowerCase(),.eq('ADA')compares with'ada'. A value that fails the field's checks is sent as given. - Paths reach nested values.
where('meta.lane')andwhere('lines[0]')work, typed through objects and lists. - Lists and maps compare as whole values. An equal list in another order does not match.
undefinedthrows.eq(undefined)throws anExpressionError: DynamoDB would reject the expression. To check for a missing attribute, useattributeNotExists().
When the condition fails
DynamoDB refuses the write and the SDK throws a ConditionalCheckFailedException. dynotable-orm
re-throws it unchanged. isConditionalCheckFailed(e) tells it from other errors:
import {isConditionalCheckFailed, model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Doc = model(
'Doc',
z.object({id: z.string().hashKey(), body: z.string(), version: z.number().int().default(0)})
);
// Optimistic locking: write only if no one else wrote since the read.
export async function save(id: string, body: string): Promise<boolean> {
const current = await Doc.get(id);
if (!current) return false;
try {
await Doc.update(
{id},
{body, $ADD: {version: 1}},
{condition: Doc.condition().where('version').eq(current.version)}
);
return true;
} catch (e) {
if (isConditionalCheckFailed(e)) return false; // changed since the read: read again
throw e;
}
}isConditionalCheckFailed matches the error by its name, so it works with any copy of the AWS SDK.
dynamoose code that tests e.code === 'ConditionalCheckFailedException' never matches with SDK v3,
whose errors have no code. A missing item fails a condition too, unless the condition accepts absent
attributes.
To see the stored item that failed the condition, pass returnValuesOnConditionCheckFailure: 'ALL_OLD' to
create, update or delete. The error keeps its class, and carries the item, read as get reads it,
in error.item.
In a transaction, a failed condition cancels the whole transaction. The SDK then throws a
TransactionCanceledException instead, and isConditionalCheckFailed is false for it. Read the
reasons array that transaction() adds: see when a transaction
fails.
Recipes
import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';
extendZod(z);
const Stock = model(
'Stock',
z.object({sku: z.string().hashKey(), count: z.number().int(), reserved: z.boolean().optional()})
);
await Stock.create({sku: 'a', count: 1});
// Only update an item that exists (an update alone would create it).
await Stock.update(
{sku: 'a'},
{reserved: true},
{condition: Stock.condition().where('sku').attributeExists()}
);
// Never go below zero: the $ADD is a delta, so the schema cannot check it.
await Stock.update(
{sku: 'a'},
{$ADD: {count: -1}},
{condition: Stock.condition().where('count').ge(1)}
);
// Delete only an item in a known state.
await Stock.delete({sku: 'a'}, {condition: Stock.condition().where('count').eq(0)});Other forms
The typed chain is the usual form. dynamoose's other forms work too:
import {Condition, model} from 'dynotable-orm';
import {z} from 'zod';
const Task = model(
'Task',
z.object({id: z.string(), status: z.string(), points: z.number(), owner: z.string().optional()}),
{hashKey: 'id'}
);
// An untyped Condition: checked against the model when the request is built.
const untyped = new Condition('status').eq('open');
// The object form: {attribute: value} is an equality, {attribute: {op: value}} names the comparison.
const object = new Condition({status: 'open', points: {gt: 1}, owner: {exists: false}});
// A raw condition, passed through. Its placeholders start with # and :.
const raw = {
ConditionExpression: '#s = :s',
ExpressionAttributeNames: {'#s': 'status'},
ExpressionAttributeValues: {':s': {S: 'open'}}
};
for (const condition of [untyped, object, raw]) {
const request = await Task.delete({id: 't1'}, {condition, return: 'request'});
console.log(request.ConditionExpression);
}- An untyped
Conditionwith an attribute the model does not declare throws anExpressionError('nope' is not an attribute of the model) when the request is built. - The object form takes one comparison per attribute.
{points: {gt: 1, lt: 5}}throws: chain two comparisons instead.{owner: {exists: false}}rendersattribute_not_exists. - A raw condition's placeholders that collide with the generated ones are renamed, so it can be mixed with an update's own placeholders.
The differences from dynamoose's conditions, such as Dates now being allowed and the errors that replace broken expressions, are listed under Conditions in the migration guide.
Next steps
- Query and scan: the same chain as key conditions and filters.
- The
ConditionandisConditionalCheckFailed()reference.