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" })Handling a Rejected Condition
Section titled “Handling a Rejected Condition”.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")), )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 |
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.
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(6)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 |
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
DateTimeset at["days", 0]is stored as its ISO string, or as a number understoredAs. - A
Schema.NumberFromStringvalue 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. EveryDateTime,DateandRedactedinside 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 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