Expressions
DynamoDB uses expressions — string-based mini-languages — to describe conditions, filters, updates, and projections. effect-dynamodb provides two ways to build them:
- Callback API — Type-safe, composable, supports nested paths and all DynamoDB operators
- Shorthand syntax — Concise object literals for common AND-equality patterns
Both compile to the same DynamoDB expression strings with properly aliased ExpressionAttributeNames and ExpressionAttributeValues.
Expression Types at a Glance
Section titled “Expression Types at a Glance”| DynamoDB Expression | Purpose | effect-dynamodb API |
|---|---|---|
ConditionExpression | Guard writes — fail if condition is false | Entity.condition() on put/update/delete |
FilterExpression | Narrow reads — remove non-matching items from results | Entity.filter() on query/scan |
UpdateExpression | Describe mutations — SET, REMOVE, ADD, DELETE clauses | Entity.set(), remove(), add(), etc. |
KeyConditionExpression | Select partition + sort key range | db.entities.Entity.indexName(), .where() |
ProjectionExpression | Select attributes to return | Entity.select() |
Condition Expressions
Section titled “Condition Expressions”Condition expressions guard write operations. DynamoDB evaluates the condition atomically — if it fails, the write is rejected with ConditionalCheckFailed.
Callback API
Section titled “Callback API”The callback receives two arguments:
t— aPathBuilderproxy for type-safe attribute accessops— comparison and logical operators that returnExprnodes
// Single comparisonProducts.condition((t, { eq }) => eq(t.status, "active"))
// Multiple conditions with ANDProducts.condition((t, { eq, gt, and }) => and(eq(t.status, "active"), gt(t.stock, 0)))
// Nested pathProducts.condition((t, { eq }) => eq(t.address.city, "NYC"))
// Size comparisonProducts.condition((t, { gt }) => gt(t.tags.size(), 5))
// Attribute existenceProducts.condition((t, { exists }) => exists(t.email))
// OR + NOT compositionProducts.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))Shorthand Syntax
Section titled “Shorthand Syntax”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 Operators Reference
Section titled “Condition Operators Reference”| Operator | Callback | DynamoDB |
|---|---|---|
| Equals | eq(t.field, value) | #field = :val |
| Not equals | ne(t.field, value) | #field <> :val |
| Less than | lt(t.field, value) | #field < :val |
| Less or equal | lte(t.field, value) | #field <= :val |
| Greater than | gt(t.field, value) | #field > :val |
| Greater or equal | gte(t.field, value) | #field >= :val |
| Between | between(t.field, low, high) | #field BETWEEN :lo AND :hi |
| In list | isIn(t.field, [a, b, c]) | #field IN (:a, :b, :c) |
| Begins with | beginsWith(t.field, prefix) | begins_with(#field, :prefix) |
| Contains | contains(t.field, substr) | contains(#field, :substr) |
| Exists | exists(t.field) | attribute_exists(#field) |
| Not exists | notExists(t.field) | attribute_not_exists(#field) |
| Attribute type | attributeType(t.field, "S") | attribute_type(#field, :type) |
| Size | gt(t.field.size(), 5) | size(#field) > :val |
Logical Composition
Section titled “Logical Composition”| Combinator | Callback | DynamoDB |
|---|---|---|
| AND | and(exprA, exprB, ...) | (exprA) AND (exprB) |
| OR | or(exprA, exprB, ...) | (exprA) OR (exprB) |
| NOT | not(expr) | NOT (expr) |
Filter Expressions
Section titled “Filter Expressions”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.
Callback API
Section titled “Callback API”Entity.filter() works identically to Entity.condition() but returns a Query combinator:
// Filter on entity index query — shorthand for equalityconst activeElectronics = yield* db.entities.Products.byCategory({ category: "electronics" }) .filter({ status: "active" }) .collect()
// Filter on scan — callback with typed PathBuilderconst withWidget = yield* db.entities.Products.scan() .filter((t, { contains }) => contains(t.name, "Widget")) .collect()
// Complex filter with ORconst activeOrPending = yield* db.entities.Products.scan() .filter((t, { or, eq }) => or(eq(t.status, "active"), eq(t.status, "pending"))) .collect()Shorthand Syntax
Section titled “Shorthand Syntax”Products.filter({ status: "active" })Products.filter({ category: "electronics", inStock: true })Update Expressions
Section titled “Update Expressions”Update expressions describe how to modify item attributes. DynamoDB supports four clause types, all composable in a single atomic operation.
Record-Based Combinators
Section titled “Record-Based Combinators”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"]), })Composing Multiple Update Types
Section titled “Composing Multiple Update Types”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)Path-Based Combinators
Section titled “Path-Based Combinators”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", })Update Expression Reference
Section titled “Update Expression Reference”| Operation | Record Combinator | Path Combinator | DynamoDB |
|---|---|---|---|
| Set value | Entity.set({ field: value }) | Entity.pathSet(...) | SET #f = :v |
| Remove attribute | Entity.remove(["field"]) | Entity.pathRemove(segs) | REMOVE #f |
| Atomic add | Entity.add({ field: n }) | Entity.pathAdd(...) | ADD #f :v |
| Subtract | Entity.subtract({ field: n }) | Entity.pathSubtract(...) | SET #f = #f - :v |
| Append to list | Entity.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 set | Entity.deleteFromSet({ f: set }) | Entity.pathDelete(...) | DELETE #f :v |
| Copy attribute | — | Entity.pathSet({ isPath: true }) | SET #a = #b |
Key Condition Expressions
Section titled “Key Condition Expressions”Key conditions select which items DynamoDB reads from disk. They are the most efficient way to narrow results because they reduce consumed read capacity.
Entity Query Accessors
Section titled “Entity Query Accessors”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 clientdb.entities.Tasks.byProject({ projectId: "proj-alpha" })
// Partial SK composites narrow with begins_with automaticallydb.entities.Tasks.byProject({ projectId: "proj-alpha", status: "active" })Sort Key Conditions with .where()
Section titled “Sort Key Conditions with .where()”.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 comparisonsdb.entities.Tasks.byProject({ projectId: "p-1" }) .where((t, { gt }) => gt(t.status, "active"))
// Prefix matchdb.entities.Tasks.byProject({ projectId: "p-1" }) .where((t, { beginsWith }) => beginsWith(t.status, "a"))| Operator | Callback | DynamoDB |
|---|---|---|
| Equals | eq(t.field, value) | #sk = :sk |
| Less than | lt(t.field, value) | #sk < :sk |
| Less or equal | lte(t.field, value) | #sk <= :sk |
| Greater than | gt(t.field, value) | #sk > :sk |
| Greater or equal | gte(t.field, value) | #sk >= :sk |
| Between | between(t.field, lo, hi) | #sk BETWEEN :lo AND :hi |
| Begins with | beginsWith(t.field, prefix) | begins_with(#sk, :prefix) |
Projection Expressions
Section titled “Projection Expressions”Projections select which attributes DynamoDB returns, reducing network transfer.
Callback API with Entity.select()
Section titled “Callback API with Entity.select()”Entity.select() uses the same PathBuilder proxy as condition/filter expressions:
// Top-level fieldsProducts.select((t) => [t.name, t.status])
// Nested map pathsProducts.select((t) => [t.name, t.address.city])
// Array elementsProducts.select((t) => [t.name, t.roster.at(0).name])
// MixedProducts.select((t) => [t.name, t.address.city, t.tags.at(0)])Apply as a combinator on queries and scans:
// Project on scan — callbackconst projected = yield* db.entities.Products.scan() .select((t) => [t.name, t.price]) .collect()
// Project on scan — string array shorthandconst scanned = yield* db.entities.Products.scan().select(["name", "status"]).collect()String Array Shorthand
Section titled “String Array Shorthand”For top-level-only projections:
Products.select(["name", "status", "price"])PathBuilder
Section titled “PathBuilder”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")})Supported Path Types
Section titled “Supported Path Types”| Access | Type | Segments | DynamoDB Path |
|---|---|---|---|
t.name | Top-level attribute | ["name"] | #name |
t.address.city | Nested map | ["address", "city"] | #address.#city |
t.items.at(0) | Array element | ["items", 0] | #items[0] |
t.items.at(0).name | Nested 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 Expression Mapping Reference
Section titled “DynamoDB Expression Mapping Reference”Condition / Filter Expression Operators
Section titled “Condition / Filter Expression Operators”| DynamoDB Expression | effect-dynamodb |
|---|---|
#a = :v | eq(t.a, v) |
#a <> :v | ne(t.a, v) |
#a < :v | lt(t.a, v) |
#a <= :v | lte(t.a, v) |
#a > :v | gt(t.a, v) |
#a >= :v | gte(t.a, v) |
#a BETWEEN :lo AND :hi | between(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) > :v | gt(t.a.size(), v) |
(A) AND (B) | and(A, B) |
(A) OR (B) | or(A, B) |
NOT (A) | not(A) |
#a.#b = :v | eq(t.a.b, v) |
#a[0] = :v | eq(t.a.at(0), v) |
#a > #b | gt(t.a, t.b) |
Update Expression Operations
Section titled “Update Expression Operations”| DynamoDB Expression | effect-dynamodb Record | effect-dynamodb Path |
|---|---|---|
SET #a = :v | Entity.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 - :v | Entity.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 #a | Entity.remove(["a"]) | Entity.pathRemove(["a"]) |
REMOVE #a.#b | — | Entity.pathRemove(["a", "b"]) |
REMOVE #a[0] | — | Entity.pathRemove(["a", 0]) |
ADD #a :v | Entity.add({ a: n }) | Entity.pathAdd({ segments: ["a"], value: n }) |
DELETE #a :v | Entity.deleteFromSet({ a: set }) | Entity.pathDelete({ segments: ["a"], value: set }) |
What’s Next?
Section titled “What’s Next?”- Example: Conditional Writes & Filters — Runnable example with all expression types
- Example: Rich Updates — Atomic increments, list appends, set deletions
- Example: Projections — Selective attribute retrieval
- Queries Guide — Deep dive into query patterns and pagination
- Data Integrity Guide — Optimistic locking and unique constraints