Example: Conditional Writes & Filters
A product catalog that demonstrates conditional writes and filter expressions — the two main ways DynamoDB expressions appear in effect-dynamodb. You will use Entity.create() for duplicate prevention, Products.condition() for server-side condition checks on put/update/delete, Products.filter() for narrowing query and scan results, and the low-level Expression builders for inspecting the generated DynamoDB expressions.
What you’ll learn:
Entity.create()— put with automaticattribute_not_existsconditionProducts.condition()— callback API and shorthand on put, update, and deleteProducts.filter()— callback API and shorthand on queries and scansProducts.select()— path-based projections- Combining conditions with optimistic locking (
expectedVersion) Expression.condition()andExpression.update()low-level builders- Error handling with
ConditionalCheckFailed
Step 1: Model
Section titled “Step 1: Model”A pure domain model with no DynamoDB concepts:
class Product extends Schema.Class<Product>("Product")({ productId: Schema.String, name: Schema.NonEmptyString, category: Schema.String, price: Schema.Number, stock: Schema.Number,}) {}Step 2: Schema, Table, and Entity
Section titled “Step 2: Schema, Table, and Entity”const AppSchema = DynamoSchema.make({ name: "expr-demo", version: 1 })const Products = Entity.make({ model: Product, entityType: "Product", primaryKey: { pk: { field: "pk", composite: ["productId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byCategory: { name: "gsi1", pk: { field: "gsi1pk", composite: ["category"] }, sk: { field: "gsi1sk", composite: ["productId"] }, }, }, timestamps: true, versioned: true,})const MainTable = Table.make({ schema: AppSchema, entities: { Products } })versioned: true enables optimistic locking — every write increments a version counter, and expectedVersion() can enforce it.
Step 3: Operations
Section titled “Step 3: Operations”Entity.create() — Insert-Only Writes
Section titled “Entity.create() — Insert-Only Writes”Entity.create() is put() with an automatic attribute_not_exists condition on the primary key. If the item already exists, it fails with ConditionalCheckFailed:
// #region create // create() is put() + automatic attribute_not_exists on primary key. // It fails with ConditionalCheckFailed if an item already exists. const widget = yield* db.entities.Products.create({ productId: "p-1", name: "Widget", category: "electronics", price: 29.99, stock: 100, })
// Try to create the same item again — should fail const duplicateResult = yield* db.entities.Products.create({ productId: "p-1", name: "Widget Duplicate", category: "electronics", price: 19.99, stock: 50, }) .asEffect() .pipe( Effect.map(() => "unexpected success"), Effect.catchTag("ConditionalCheckFailed", () => Effect.succeed("ConditionalCheckFailed — duplicate prevented!"), ), )BoundEntity methods return Effect directly, so you can pipe to Effect.catchTag and other combinators.
Conditional Put
Section titled “Conditional Put”Products.condition() adds a server-side ConditionExpression to any write operation. The condition is evaluated atomically — if it fails, the write is rejected with ConditionalCheckFailed:
// #region conditional-put // .condition() adds a ConditionExpression to a put operation. // The condition is evaluated server-side. If it fails, the put is rejected. const condPutResult = yield* db.entities.Products.put({ productId: "p-5", name: "New Product", category: "electronics", price: 49.99, stock: 10, }) // Only put if item doesn't already exist (same as create, but explicit) .condition((t, { notExists }) => notExists(t.productId))Conditional Delete
Section titled “Conditional Delete”Delete an item only when a condition is met. Here we delete only if the product has zero stock:
// #region conditional-delete // Delete only if the item's stock is 0 (don't delete items with stock). // .condition() maps ConditionalCheckFailedException at runtime. // Use Effect.match to handle both success and failure paths. const condDeleteResult = yield* db.entities.Products.delete({ productId: "p-4" }) .condition((t, { eq }) => eq(t.stock, 0)) .asEffect() .pipe( Effect.match({ onFailure: (e) => `Failed: ${e._tag}`, onSuccess: () => "deleted (stock was 0)", }), )
// Try to conditionally delete an item with stock > 0 const condDeleteFail = yield* db.entities.Products.delete({ productId: "p-1" }) .condition((t, { eq }) => eq(t.stock, 0)) .asEffect() .pipe( Effect.match({ onFailure: (e) => `${e._tag} — item has stock (correct!)`, onSuccess: () => "deleted", }), )Conditional Update + Optimistic Locking
Section titled “Conditional Update + Optimistic Locking”Combine Products.condition() with expectedVersion() and set(). The user condition is ANDed with the optimistic lock condition:
// #region conditional-update // Combine .condition() with .expectedVersion() and .set(). // The user condition is ANDed with the optimistic lock condition. const updated = yield* db.entities.Products.update({ productId: "p-1" }) .set({ price: 24.99 }) .expectedVersion(1) .condition((t, { gt }) => gt(t.stock, 0))When the condition fails, you get a ConditionalCheckFailed error:
// #region conditional-update-fail // Condition fails: update only if stock > 1000 (it's 100) const condUpdateFail = yield* db.entities.Products.update({ productId: "p-1" }) .set({ price: 19.99 }) .condition((t, { gt }) => gt(t.stock, 1000)) .asEffect() .pipe( Effect.match({ onFailure: (e) => `${e._tag} — stock not > 1000`, onSuccess: () => "updated", }), )Callback and Shorthand Conditions
Section titled “Callback and Shorthand Conditions”There are two ways to build conditions — a callback API using a type-safe PathBuilder, and a shorthand with plain objects:
// #region condition-callback // The callback API provides type-safe conditions using PathBuilder. // First argument `t` is a path proxy, second `ops` has comparison functions. const conditionUpdated = yield* db.entities.Products.update({ productId: "p-2" }) .set({ price: 79.99 }) // Type-safe: t.stock is typed as number, "active" would be a type error .condition((t, { gt }) => gt(t.stock, 0))
// Shorthand condition — plain object for simple equality const shorthandResult = yield* db.entities.Products.update({ productId: "p-2" }) .set({ price: 84.99 }) .condition({ category: "electronics" }) .asEffect() .pipe( Effect.match({ onFailure: (e) => `Failed: ${e._tag}`, onSuccess: (p) => `Shorthand condition: ${p.name} price=$${p.price}`, }), )Filter Expressions
Section titled “Filter Expressions”Filter expressions narrow query and scan results. They are applied server-side after items are read, reducing network transfer (but not read capacity).
Products.filter() uses the same callback + shorthand API as Products.condition(), applied as a combinator on queries and scans:
// All electronics via entity index queryconst electronics = yield* db.entities.Products.byCategory({ category: "electronics" }).collect()// Greater-than filter on scanconst expensive = yield* db.entities.Products.scan() .filter((t, { gt }) => gt(t.price, 30)) .collect()// Between filter on scanconst midRange = yield* db.entities.Products.scan() .filter((t, { between }) => between(t.price, 20, 50)) .collect()// Contains filter (string substring match) on scanconst withWidget = yield* db.entities.Products.scan() .filter((t, { contains }) => contains(t.name, "Widget")) .collect()// Attribute exists/not existsconst allWithStock = yield* db.entities.Products.scan() .filter((t, { exists }) => exists(t.stock)) .collect()Filter with callback supports nested paths, OR composition, and all DynamoDB operators:
// Filter with callback on scan — supports nested paths, OR, and all DynamoDB operatorsconst expensiveProducts = yield* db.entities.Products.scan() .filter((t, { gt }) => gt(t.price, 30)) .collect()// OR composition in filter — scan with filterconst multiCategory = yield* db.entities.Products.scan() .filter((t, { or, eq }) => or(eq(t.category, "electronics"), eq(t.category, "accessories"))) .collect()Projections with Entity.select()
Section titled “Projections with Entity.select()”Select specific attributes to reduce network transfer:
// Path-based projection — type-safe attribute selection on scanconst namesAndPrices = yield* db.entities.Products.scan() .select((t) => [t.name, t.price]) .collect()// String array shorthand on scanconst nameOnly = yield* db.entities.Products.scan().select(["name", "category"]).collect()Low-Level Expression Builders
Section titled “Low-Level Expression Builders”The Expression module exposes the raw builders used internally. These are useful for inspecting what DynamoDB expressions are generated:
// Expression.condition() builds raw ConditionExpression strings.// These are what Products.condition() uses under the hood.const cond1 = Expression.condition({ eq: { status: "active" }, gt: { stock: 0 },})// cond1.expression → "#status = :status AND #stock > :stock"// cond1.names → { "#status": "status", "#stock": "stock" }// cond1.values → { ":status": { S: "active" }, ":stock": { N: "0" } }const cond2 = Expression.condition({ between: { price: [10, 50] }, attributeExists: "category",})// Update expression with SET, REMOVE, and ADD clausesconst upd = Expression.update({ set: { name: "New Name", price: 39.99 }, remove: ["description"], add: { viewCount: 1 },})// upd.expression → "SET #name = :name, #price = :price REMOVE #description ADD #viewCount :viewCount"Running the Example
Section titled “Running the Example”The complete runnable example is at examples/expressions.ts in the repository.
Start DynamoDB Local:
docker run -d -p 8000:8000 amazon/dynamodb-localRun the example:
npx tsx examples/expressions.tsLayer Setup
Section titled “Layer Setup”const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), MainTable.layer({ name: "expr-demo-table" }),)const main = program.pipe(Effect.provide(AppLayer))Effect.runPromise(main).then( () => console.log("\nDone."), (err) => console.error("\nFailed:", err),)Key Takeaways
Section titled “Key Takeaways”| Concept | How it’s used |
|---|---|
| Entity.create() | put() + automatic attribute_not_exists — prevents duplicates |
| Products.condition() | Callback: (t, { eq }) => eq(t.status, "active") — type-safe, nested paths |
| Products.condition() | Shorthand: { status: "active" } — simple AND-equality |
| Products.filter() | Callback: (t, { gt }) => gt(t.price, 30) or shorthand: { status: "active" } |
| Products.select() | Path-based projection: (t) => [t.name, t.price] |
| expectedVersion() | Optimistic locking — user conditions are ANDed with version check |
| ConditionalCheckFailed | Tagged error returned when any condition fails — use catchTag to handle |
| Expression.condition() | Low-level builder for inspecting generated ConditionExpression strings |
| Expression.update() | Low-level builder for SET, REMOVE, ADD, DELETE update expressions |
What’s Next?
Section titled “What’s Next?”- Expressions Guide — Comprehensive reference with DynamoDB mapping tables, all operators, and PathBuilder
- Example: Scan Operations — Full-table scans with filters, limits, and entity-type isolation
- Example: Projections — Selective attribute retrieval with ProjectionExpression
- Queries Guide — Deep dive into query patterns, pagination, and sort key conditions
- Data Integrity Guide — Optimistic locking, conditional writes, and unique constraints