Skip to content

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 automatic attribute_not_exists condition
  • Products.condition() — callback API and shorthand on put, update, and delete
  • Products.filter() — callback API and shorthand on queries and scans
  • Products.select() — path-based projections
  • Combining conditions with optimistic locking (expectedVersion)
  • Expression.condition() and Expression.update() low-level builders
  • Error handling with ConditionalCheckFailed

A pure domain model with no DynamoDB concepts:

models.ts
class Product extends Schema.Class<Product>("Product")({
productId: Schema.String,
name: Schema.NonEmptyString,
category: Schema.String,
price: Schema.Number,
stock: Schema.Number,
}) {}

entities.ts
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.


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.

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))

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",
}),
)

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",
}),
)

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 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 query
const electronics = yield* db.entities.Products.byCategory({ category: "electronics" }).collect()
// Greater-than filter on scan
const expensive = yield* db.entities.Products.scan()
.filter((t, { gt }) => gt(t.price, 30))
.collect()
// Between filter on scan
const midRange = yield* db.entities.Products.scan()
.filter((t, { between }) => between(t.price, 20, 50))
.collect()
// Contains filter (string substring match) on scan
const withWidget = yield* db.entities.Products.scan()
.filter((t, { contains }) => contains(t.name, "Widget"))
.collect()
// Attribute exists/not exists
const 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 operators
const expensiveProducts = yield* db.entities.Products.scan()
.filter((t, { gt }) => gt(t.price, 30))
.collect()
// OR composition in filter — scan with filter
const multiCategory = yield* db.entities.Products.scan()
.filter((t, { or, eq }) => or(eq(t.category, "electronics"), eq(t.category, "accessories")))
.collect()

Select specific attributes to reduce network transfer:

// Path-based projection — type-safe attribute selection on scan
const namesAndPrices = yield* db.entities.Products.scan()
.select((t) => [t.name, t.price])
.collect()
// String array shorthand on scan
const nameOnly = yield* db.entities.Products.scan().select(["name", "category"]).collect()

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 clauses
const 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"

The complete runnable example is at examples/expressions.ts in the repository.

Start DynamoDB Local:

Terminal window
docker run -d -p 8000:8000 amazon/dynamodb-local

Run the example:

Terminal window
npx tsx examples/expressions.ts
main.ts
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),
)

ConceptHow 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
ConditionalCheckFailedTagged 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