Skip to content

Expressions

DynamoDB uses expressions — string-based mini-languages — to describe conditions, filters, updates, and projections. effect-dynamodb provides two ways to build them:

  1. Callback API — Type-safe, composable, supports nested paths and all DynamoDB operators
  2. Shorthand syntax — Concise object literals for common AND-equality patterns

Both compile to the same DynamoDB expression strings with properly aliased ExpressionAttributeNames and ExpressionAttributeValues.

DynamoDB ExpressionPurposeeffect-dynamodb API
ConditionExpressionGuard writes — fail if condition is falseEntity.condition() on put/update/delete
FilterExpressionNarrow reads — remove non-matching items from resultsEntity.filter() on query/scan
UpdateExpressionDescribe mutations — SET, REMOVE, ADD, DELETE clausesEntity.set(), remove(), add(), etc.
KeyConditionExpressionSelect partition + sort key rangedb.entities.Entity.indexName(), .where()
ProjectionExpressionSelect attributes to returnEntity.select()

Condition expressions guard write operations. DynamoDB evaluates the condition atomically — if it fails, the write is rejected with ConditionalCheckFailed.

The callback receives two arguments:

  • t — a PathBuilder proxy for type-safe attribute access
  • ops — comparison and logical operators that return Expr nodes
// Single comparison
Products.condition((t, { eq }) => eq(t.status, "active"))
// Multiple conditions with AND
Products.condition((t, { eq, gt, and }) => and(eq(t.status, "active"), gt(t.stock, 0)))
// Nested path
Products.condition((t, { eq }) => eq(t.address.city, "NYC"))
// Size comparison
Products.condition((t, { gt }) => gt(t.tags.size(), 5))
// Attribute existence
Products.condition((t, { exists }) => exists(t.email))
// OR + NOT composition
Products.condition((t, { or, not, eq }) =>
or(eq(t.status, "active"), not(eq(t.status, "archived"))),
)

Apply the condition as a combinator on put, update, or delete:

// #region condition-on-operations
// On put
yield* db.entities.Products.put({
...baseProduct,
productId: "p-5",
name: "New Product",
category: "electronics",
price: 49.99,
stock: 10,
}).condition((t, { notExists }) => notExists(t.productId))
// On update (ANDed with optimistic lock)
yield* db.entities.Products.update({ productId: "p-1" })
.set({ name: "Widget Pro" })
.expectedVersion(1)
.condition((t, { gt }) => gt(t.stock, 0))
// On delete
yield* db.entities.Products.delete({ productId: "p-4" }).condition((t, { eq }) => eq(t.stock, 0))

For simple equality-only conditions, pass a plain object. Each key-value pair becomes an = comparison, all ANDed together:

// Equivalent to: eq(t.status, "active") AND eq(t.role, "admin")
Products.condition({ status: "active", role: "admin" })

.condition() widens the operation’s error channel with ConditionalCheckFailed, so a rejection is catchable by tag — the exhaustiveness of catchTag / catchTags is preserved, and a new failure introduced by an upgrade cannot silently fall into the same branch:

// #region condition-catch
// `.condition()` adds ConditionalCheckFailed to the error channel, so the
// rejection is catchable by tag — no `catchAll` + `_tag` inspection needed.
const outcome = yield* db.entities.Products.put({
...baseProduct,
productId: "p-5",
name: "Duplicate",
category: "electronics",
price: 49.99,
stock: 10,
})
.condition((t, { notExists }) => notExists(t.productId))
.asEffect()
.pipe(
Effect.as("written"),
Effect.catchTag("ConditionalCheckFailed", () => Effect.succeed("already exists")),
)
OperatorCallbackDynamoDB
Equalseq(t.field, value)#field = :val
Not equalsne(t.field, value)#field <> :val
Less thanlt(t.field, value)#field < :val
Less or equallte(t.field, value)#field <= :val
Greater thangt(t.field, value)#field > :val
Greater or equalgte(t.field, value)#field >= :val
Betweenbetween(t.field, low, high)#field BETWEEN :lo AND :hi
In listisIn(t.field, [a, b, c])#field IN (:a, :b, :c)
Begins withbeginsWith(t.field, prefix)begins_with(#field, :prefix)
Containscontains(t.field, substr)contains(#field, :substr)
Existsexists(t.field)attribute_exists(#field)
Not existsnotExists(t.field)attribute_not_exists(#field)
Attribute typeattributeType(t.field, "S")attribute_type(#field, :type)
Sizegt(t.field.size(), 5)size(#field) > :val

An isIn with no values compiles to #field IN (), which DynamoDB rejects as a syntax error. The library sends it rather than refusing it, and fails with a ValidationError naming the isIn() and its attribute, with DynamoDB’s error as its cause — in a condition (nothing is written) and in a filter (operation: "query.filter") alike. Give isIn at least one value, or leave the condition out.

CombinatorCallbackDynamoDB
ANDand(exprA, exprB, ...)(exprA) AND (exprB)
ORor(exprA, exprB, ...)(exprA) OR (exprB)
NOTnot(expr)NOT (expr)

Filter expressions narrow query and scan results after items are read from disk. They use the same grammar as condition expressions but apply to reads instead of writes.

Entity.filter() works identically to Entity.condition() but returns a Query combinator:

// Filter on entity index query — shorthand for equality
const activeElectronics = yield* db.entities.Products.byCategory({ category: "electronics" })
.filter({ status: "active" })
.collect()
// Filter on scan — callback with typed PathBuilder
const withWidget = yield* db.entities.Products.scan()
.filter((t, { contains }) => contains(t.name, "Widget"))
.collect()
// Complex filter with OR
const activeOrPending = yield* db.entities.Products.scan()
.filter((t, { or, eq }) => or(eq(t.status, "active"), eq(t.status, "pending")))
.collect()
Products.filter({ status: "active" })
Products.filter({ category: "electronics", inStock: true })

Update expressions describe how to modify item attributes. DynamoDB supports four clause types, all composable in a single atomic operation.

These accept plain objects with field names — good for top-level attribute updates:

// #region update-record
// SET — assign values
yield* db.entities.Products.update({ productId: "p-1" }).set({
name: "Widget Deluxe",
price: 34.99,
})
// REMOVE — delete attributes
yield* db.entities.Products.update({ productId: "p-4" }).remove(["description", "temporaryFlag"])
// ADD — atomic increment (numbers) or union (sets)
yield* db.entities.Products.update({ productId: "p-1" }).add({ viewCount: 1, stock: 50 })
// Subtract — SET field = field - value
yield* db.entities.Products.update({ productId: "p-1" }).subtract({ stock: 3 })
// Append — SET field = list_append(field, value)
yield* db.entities.Products.update({ productId: "p-1" }).append({
tags: ["on-sale", "featured"],
})
// DELETE — remove elements from a set
yield* db.entities.Products.update({ productId: "p-4" }).deleteFromSet({
categories: new Set(["obsolete"]),
})

All combinators chain as methods on the BoundUpdate builder. They compile to a single UpdateExpression with the appropriate SET, REMOVE, ADD, and DELETE clauses:

// #region update-composed
yield* db.entities.Products.update({ productId: "p-1" })
.set({ name: "Updated Widget", price: 24.99 })
.add({ viewCount: 1 })
.subtract({ stock: 3 })
.append({ tags: ["clearance"] })
.remove(["temporaryFlag"])
.expectedVersion(6)

For nested attributes, array elements, and attribute-to-attribute operations, use the path-based combinators. These take the update operation as the first argument, so wrap them in a lambda when passing as a combinator to update():

// #region update-path
// SET nested path
yield* db.entities.Products.update({ productId: "p-1" }).pathSet({
segments: ["address", "city"],
value: "NYC",
isPath: false,
})
// SET array element
yield* db.entities.Products.update({ productId: "p-1" }).pathSet({
segments: ["roster", 0, "position"],
value: "captain",
isPath: false,
})
// Copy attribute to attribute
yield* db.entities.Products.update({ productId: "p-1" }).pathSet({
segments: ["backup_email"],
value: undefined,
isPath: true,
valueSegments: ["email"],
})
// REMOVE nested attribute
yield* db.entities.Products.update({ productId: "p-1" }).pathRemove(["metadata", "temporary"])
// PREPEND to list
yield* db.entities.Products.update({ productId: "p-1" }).pathPrepend({
segments: ["tags"],
value: ["URGENT"],
})
// if_not_exists — set only if the attribute doesn't exist
yield* db.entities.Products.update({ productId: "p-1" }).pathIfNotExists({
segments: ["createdBy"],
value: "system",
})
OperationRecord CombinatorPath CombinatorDynamoDB
Set valueEntity.set({ field: value })Entity.pathSet(...)SET #f = :v
Remove attributeEntity.remove(["field"])Entity.pathRemove(segs)REMOVE #f
Atomic addEntity.add({ field: n })Entity.pathAdd(...)ADD #f :v
SubtractEntity.subtract({ field: n })Entity.pathSubtract(...)SET #f = #f - :v
Append to listEntity.append({ field: [...] })Entity.pathAppend(...)SET #f = list_append(#f, :v)
Prepend to list—Entity.pathPrepend(...)SET #f = list_append(:v, #f)
Default value—Entity.pathIfNotExists(...)SET #f = if_not_exists(#f, :v)
Delete from setEntity.deleteFromSet({ f: set })Entity.pathDelete(...)DELETE #f :v
Copy attribute—Entity.pathSet({ isPath: true })SET #a = #b

Values are encoded through the schema at the path

Section titled “Values are encoded through the schema at the path”

pathSet, pathAppend, pathPrepend, pathIfNotExists and the record-based append encode their value through the model schema at the path they address, the same way set does:

  • A DateTime set at ["days", 0] is stored as its ISO string, or as a number under storedAs.
  • A Schema.NumberFromString value is stored as a string.
  • A plain object set on a class-typed field is encoded as that class.
  • A class instance is always encoded as a whole, since its constructor took domain values.
  • A plain object or array that mixes wire and domain parts, such as { b64: "hi", at: DateTime, n: "5" }, is encoded part by part, by the schema at each position. Every DateTime, Date and Redacted inside it is stored in its wire form.
  • Appended and prepended lists are encoded element by element.

A value already in wire form is normalised the way .set() normalises it. A NumberFromString value of "05" is stored as "5", a DateTimeUtcFromString value of "2000-01-01" as "2000-01-01T00:00:00.000Z", and a Schema.Trim field stores its trimmed form. The value read back is the same either way; only the stored bytes differ, which matters to filters and DynamoDB Streams consumers.

One kind of value is stored exactly as given: a value that is genuinely valid wire for a leaf transform with a primitive wire form, and also a valid domain value. Examples are "aGk=" on a Schema.StringFromBase64 field, or a JSON string on a Schema.fromJsonString field. Encoding such a value again would double-encode it. A plain string that isn’t valid wire, such as "hi" on a StringFromBase64 field, is a domain value and is encoded ("aGk="). The same rule applies to such leaves inside arrays and plain objects.

Paths into and under a top-level DynamoModel.ref field are encoded through the ref target’s model, like any other path: pathSet at ["author", "born"] stores the author’s date as an ISO string. Only a path no schema describes, such as one under a dynamic key of an untyped value, is written as given.

A value set by path into a class that has lost its fields is written as given, in the same form put stores it, so the item stays readable. A class loses its fields when it is built with .check() or .annotate(), or when it is a DynamoModel.ref nested inside a ref target. Known limitation: plain dates inside such a class are still stored as maps, as on 1.22.0, and read back as plain objects. That applies to whole-value put, .set() and pathSet of the class too.

On entities with versioned: { retain: true }, path operations were silently ignored before this release: they returned success and wrote nothing. They are now sent to DynamoDB as the same update expression other entities use, in one transaction with the version snapshot, so they follow DynamoDB’s own semantics: list indexes refer to the item before the update, overlapping paths and appends to a missing list are rejected, and a rejected update writes no snapshot. expectedVersion and .condition() apply.

Path operations on index composites and unique fields

Section titled “Path operations on index composites and unique fields”

A path operation on a field that feeds an index key or a unique constraint is handled like the matching record operation. A top-level pathSet of a value or pathRemove works like .set() or .remove(), and a numeric pathAdd or pathSubtract works like .add() or .subtract(). The index keys are recomposed and the unique sentinels rotated, exactly as those operations do. .add(), .subtract(), .append() and .deleteFromSet() on an index composite also recompose the index key, on every entity.

A .remove() (or top-level pathRemove) of an index composite that has a Schema.withDecodingDefault doesn’t drop the item from the index. It stores the default instead and keeps the item indexed under it, so the keys agree with the value the item reads back with. On a unique field, the old value’s sentinel is released and none is created for the default (see Defaults and Unique Constraints).

These are rejected with a ValidationError that names the field:

  • operations whose result DynamoDB computes at write time on such a field: a copy from another attribute, pathIfNotExists, and list or set operations;
  • a path below such a field;
  • any path operation on a primary-key composite or an immutable field;
  • more than one operation on the same such field.

An update can’t combine path operations with a change to a unique-constraint field, or with a computed change (.add(), .append(), …) to an index composite. Split it into two updates.

Path values are also validated the way .set() validates its payload. A value the schema rejects, such as a literal outside its set, a string under its minLength, or a container that breaks its .check(), fails with a ValidationError instead of being stored. A key whose value is undefined is dropped before validation, as it is when the item is stored. List append and prepend validate each element, but can’t enforce list-level checks such as maxLength, because DynamoDB builds the list server-side.

ADD, DELETE and SUBTRACT are unchanged.


Key conditions select which items DynamoDB reads from disk. They are the most efficient way to narrow results because they reduce consumed read capacity.

Each non-primary index defined on an entity generates a named query accessor:

const Tasks = Entity.make({
model: Task,
entityType: "Task",
primaryKey: {
pk: { field: "pk", composite: ["taskId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byProject: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["projectId"] },
sk: { field: "gsi1sk", composite: ["status", "createdAt"] },
},
},
})
// Access via entity index accessor on the typed client
db.entities.Tasks.byProject({ projectId: "proj-alpha" })
// Partial SK composites narrow with begins_with automatically
db.entities.Tasks.byProject({ projectId: "proj-alpha", status: "active" })

.where() on a BoundQuery adds a typed sort key condition to the KeyConditionExpression, using the SK composites that have not yet been provided in the query accessor input. It is only available when remaining SK composites exist, and can only be called once per query.

The callback receives a typed accessor for the remaining SK composites plus a set of SK comparison ops:

// byProject SK composite: ["status", "createdAt"]
// Provide the PK + no SK composites → .where() sees both { status, createdAt }
db.entities.Tasks.byProject({ projectId: "p-1" })
.where((t, { eq }) => eq(t.status, "active"))
// Provide a leading SK composite → .where() sees only remaining { createdAt }
db.entities.Tasks.byProject({ projectId: "p-1", status: "active" })
.where((t, { between }) => between(t.createdAt, "2025-01", "2025-03"))
// Range comparisons
db.entities.Tasks.byProject({ projectId: "p-1" })
.where((t, { gt }) => gt(t.status, "active"))
// Prefix match
db.entities.Tasks.byProject({ projectId: "p-1" })
.where((t, { beginsWith }) => beginsWith(t.status, "a"))
OperatorCallbackDynamoDB
Equalseq(t.field, value)#sk = :sk
Less thanlt(t.field, value)#sk < :sk
Less or equallte(t.field, value)#sk <= :sk
Greater thangt(t.field, value)#sk > :sk
Greater or equalgte(t.field, value)#sk >= :sk
Betweenbetween(t.field, lo, hi)#sk BETWEEN :lo AND :hi
Begins withbeginsWith(t.field, prefix)begins_with(#sk, :prefix)

Projections select which attributes DynamoDB returns, reducing network transfer.

Entity.select() uses the same PathBuilder proxy as condition/filter expressions:

// Top-level fields
Products.select((t) => [t.name, t.status])
// Nested map paths
Products.select((t) => [t.name, t.address.city])
// Array elements
Products.select((t) => [t.name, t.roster.at(0).name])
// Mixed
Products.select((t) => [t.name, t.address.city, t.tags.at(0)])

Apply as a combinator on queries and scans:

// Project on scan — callback
const projected = yield* db.entities.Products.scan()
.select((t) => [t.name, t.price])
.collect()
// Project on scan — string array shorthand
const scanned = yield* db.entities.Products.scan().select(["name", "status"]).collect()

For top-level-only projections:

Products.select(["name", "status", "price"])

The PathBuilder is a recursive Proxy that tracks attribute path segments as you access properties. It is the first argument in all callback-style expression builders.

// t is PathBuilder<Product, Product>
Products.condition((t, ops) => {
// t.name → Path with segments ["name"]
// t.address.city → Path with segments ["address", "city"]
// t.roster.at(0) → Path with segments ["roster", 0]
// t.roster.at(0).name → Path with segments ["roster", 0, "name"]
// t.tags.size() → SizeOperand with segments ["tags"]
return ops.eq(t.status, "active")
})
AccessTypeSegmentsDynamoDB Path
t.nameTop-level attribute["name"]#name
t.address.cityNested map["address", "city"]#address.#city
t.items.at(0)Array element["items", 0]#items[0]
t.items.at(0).nameNested in array["items", 0, "name"]#items[0].#name
t.tags.size()Size function["tags"]size(#tags)

All string segments are automatically aliased through ExpressionAttributeNames to handle DynamoDB reserved words.


DynamoDB Expressioneffect-dynamodb
#a = :veq(t.a, v)
#a <> :vne(t.a, v)
#a < :vlt(t.a, v)
#a <= :vlte(t.a, v)
#a > :vgt(t.a, v)
#a >= :vgte(t.a, v)
#a BETWEEN :lo AND :hibetween(t.a, lo, hi)
#a IN (:v1, :v2)isIn(t.a, [v1, v2])
begins_with(#a, :v)beginsWith(t.a, v)
contains(#a, :v)contains(t.a, v)
attribute_exists(#a)exists(t.a)
attribute_not_exists(#a)notExists(t.a)
attribute_type(#a, :t)attributeType(t.a, "S")
size(#a) > :vgt(t.a.size(), v)
(A) AND (B)and(A, B)
(A) OR (B)or(A, B)
NOT (A)not(A)
#a.#b = :veq(t.a.b, v)
#a[0] = :veq(t.a.at(0), v)
#a > #bgt(t.a, t.b)
DynamoDB Expressioneffect-dynamodb Recordeffect-dynamodb Path
SET #a = :vEntity.set({ a: v })Entity.pathSet({ segments: ["a"], value: v, isPath: false })
SET #a.#b = :v—Entity.pathSet({ segments: ["a", "b"], value: v, isPath: false })
SET #a[0] = :v—Entity.pathSet({ segments: ["a", 0], value: v, isPath: false })
SET #a = #b—Entity.pathSet({ segments: ["a"], isPath: true, valueSegments: ["b"] })
SET #a = #a - :vEntity.subtract({ a: n })Entity.pathSubtract({ segments: ["a"], value: n, isPath: false })
SET #a = list_append(#a, :v)Entity.append({ a: [...] })Entity.pathAppend({ segments: ["a"], value: [...] })
SET #a = list_append(:v, #a)—Entity.pathPrepend({ segments: ["a"], value: [...] })
SET #a = if_not_exists(#a, :v)—Entity.pathIfNotExists({ segments: ["a"], value: v })
REMOVE #aEntity.remove(["a"])Entity.pathRemove(["a"])
REMOVE #a.#b—Entity.pathRemove(["a", "b"])
REMOVE #a[0]—Entity.pathRemove(["a", 0])
ADD #a :vEntity.add({ a: n })Entity.pathAdd({ segments: ["a"], value: n })
DELETE #a :vEntity.deleteFromSet({ a: set })Entity.pathDelete({ segments: ["a"], value: set })