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

methodrendersnotes
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 highboth 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, so a.and().b.or().c means (a AND b) OR c.
  • group() adds parentheses. It takes a function that builds the inner condition, or a Condition. parenthesis() is another name for it.
  • not() negates the next comparison: where('status').not().eq('archived') renders <>, and not().contains() renders NOT contains. Call it after where(). A not() before where(), or at the end of the chain, throws an ExpressionError: dynamoose 4.2.0 drops it there and sends the condition without the negation. beginsWith, in and between cannot follow not(), which throws an ExpressionError too.

Where a condition goes

  • create(item, {condition}), update(key, changes, {condition}) and delete(key, {condition}).
  • The same option on M.transaction.create, update and delete, and M.transaction.condition(key, condition) for a check without a write. See transactions.
  • A create without {overwrite: true} already carries attribute_not_exists on 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') and where('lines[0]') work, typed through objects and lists.
  • Lists and maps compare as whole values. An equal list in another order does not match.
  • undefined throws. eq(undefined) throws an ExpressionError: DynamoDB would reject the expression. To check for a missing attribute, use attributeNotExists().

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 Condition with an attribute the model does not declare throws an ExpressionError ('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}} renders attribute_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

Updated