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" })
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
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(5)

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 listEntity.pathPrepend(...)SET #f = list_append(:v, #f)
Default valueEntity.pathIfNotExists(...)SET #f = if_not_exists(#f, :v)
Delete from setEntity.deleteFromSet({ f: set })Entity.pathDelete(...)DELETE #f :v
Copy attributeEntity.pathSet({ isPath: true })SET #a = #b

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 = :vEntity.pathSet({ segments: ["a", "b"], value: v, isPath: false })
SET #a[0] = :vEntity.pathSet({ segments: ["a", 0], value: v, isPath: false })
SET #a = #bEntity.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.#bEntity.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 })