The fastest way to design the wrong DynamoDB helper is to look at the boilerplate and wrap it. The ceremony is a symptom. What it is a symptom of decides what a good tool should actually do.
Say you want to update one user record: set their name and status, bump a view counter by one, delete a temporary token, and refuse the whole thing if someone else has changed the row since you read it. That last part is a version check, an optimistic lock. It is a completely ordinary operation. Written by hand, the way Amazon's SDK actually wants it, it looks like this:
const params = {
TableName: 'Users',
Key: { userId: 'u-1' }, // which row
ConditionExpression: '#version = :version', // only if version still 5
UpdateExpression:
'SET #name = :name, #status = :status ADD #views :views REMOVE #tempToken',
ExpressionAttributeNames: { // what each #alias means
'#version': 'version', '#name': 'name', '#status': 'status',
'#views': 'views', '#tempToken': 'tempToken',
},
ExpressionAttributeValues: { // what each :placeholder holds
':version': 5, ':name': 'Alice', ':status': 'active', ':views': 1,
},
};
Even if you have never touched DynamoDB, look at what it costs. name is written three separate times: as #name in the update string, again in the names map to say what #name stands for, again in the values map for its value. Those two maps are mandatory and hand-maintained, in sync, on every call. Drop a #, forget an entry, or let the string and the maps disagree, and nothing warns you: the call just fails at runtime, against live data. This is the "say everything three times" tax, and you pay it on every write.
The instinct is to hide all of it behind a helper as fast as possible. That instinct, taken literally, produces a string-templating function that is worse than the problem. The better move is to ask why the ceremony exists, because the answer dictates the entire shape of the tool. This is how I ended up building ddbflow the way I did, decision by decision. It is one defensible design, not the only one, and the reasoning transfers even if the package does not.
The constraint that dictates everything
The two placeholder maps are not busywork, and they are not really the point either. ExpressionAttributeNames exists because DynamoDB reserves several hundred words (status, name, size, and a long list more), so #status aliases around them. ExpressionAttributeValues exists to keep values out of the expression string entirely, which is the exact discipline a SQL prepared statement enforces with ?: structure and data never mix. So an expression is a parametrized query, and the annoyance is only that the SDK makes you keep the binding in sync by hand.
But sit with why DynamoDB has this shape at all and the real design driver appears. DynamoDB is a distributed hash-and-range store. Every item is placed on a physical partition by hashing its key, and a read has to name that key so the request routes to the one node holding the data. There is no free-form query planner; a Scan reads the whole table. You do not get to ask arbitrary questions. You enumerate your access patterns in advance (user by id, orders for a tenant since a date, latest events for a device) and shape keys and indexes so each pattern is a cheap lookup.
That is the thesis the whole tool hangs on. A DynamoDB access pattern is a fixed query shape with holes in it, the holes are the parameters, and the natural unit of work is therefore a named, parametrized query, not an object hand-rebuilt at every call site. This is also the fork in the road for tooling: the ORMs (ElectroDB, Dynamoose) answer the same constraint by having you declare entities and generating keys from a model, which is powerful and heavy and correct for large single-table designs. I wanted the other lane, the one that keeps you at the query, so the rest of the decisions follow from choosing "parametrized query" over "entity" as the core abstraction.
Decision 1: fragments or whole operations
The first real fork is what the tool hands back. A stateless helper like @tuplo/dynoexpr returns expression fragments: it will build you a ConditionExpression and its maps, and you assemble the final params yourself. That composes beautifully and stays tiny.
ddbflow makes the opposite choice: it returns the whole ready-to-send operation. The cost is real (less composable, more surface area), and the reason is that a tool which owns the entire operation also knows the entire operation, and that knowledge is what unlocks the next two decisions. You cannot add a safe-create guard from a schema you never saw. So "emit the whole params object" is not a convenience decision, it is the enabling one.
Decision 2: one source of truth, derive the rest
Given that, the boilerplate win is almost a footnote, but it is the one you feel first. Take the exact operation from the top, the same name, status, counter, token, and version check, and write it as a chain where each field is named once:
const params = builder
.key.hash.name('userId').key.hash.condition.eq('u-1')
.condition('version').eq(5) // optimistic lock: fails if version moved
.update('name').set('Alice')
.update('status').set('active')
.update('views').add(1)
.update('tempToken').remove()
.build('update');
This emits a ready-to-send params object for that same update, except the two attribute maps are generated instead of hand-kept. name is written once, and the #name / :name bookkeeping you were maintaining by hand is produced for you, so the string and the maps cannot drift apart. Change a field and there is exactly one line to touch. That is the whole trick: the single-source-of-truth principle aimed squarely at the thing that hurts. (If you run it you will also spot ddbflow quietly adding an attribute_exists check to the condition on its own. That is not a bug, it is Decision 3, and it is the next section.)
Decision 3: put the correctness rules in the box
This is where owning the whole operation pays off, and where I think the design earns its keep. Because the builder tracks your key schema, it can fold in the guards you are supposed to remember and routinely forget. Take a "create a new user" write. In DynamoDB a plain put overwrites: if u-2 already exists, its data is silently replaced. To make it a true create you have to remember to add a condition by hand:
// PutItem, by hand: leave this line out and an existing u-2 is destroyed
ConditionExpression: 'attribute_not_exists(userId)',
ddbflow adds exactly that guard for you, because it already knows userId is the partition key:
builder.key.hash.name('userId')
.item({ userId: 'u-2', name: 'Bob' })
.build('put'); // attribute_not_exists(userId) baked in; .forceCreate() to opt out
Same story for a safe delete (attribute_exists) and for the optimistic lock, which is just one .condition('version').eq(n) in the chain. The guards are on by default and you opt out, rather than off by default and you remember.
A stateless fragment builder structurally cannot do this: it never learned your key, so it cannot know that "create" means "assert the key is absent." The design idea generalizes past DynamoDB: if your tool holds enough context to know the correctness rule, encode the rule and make forgetting it the loud path, not the quiet one.
Decision 4: opt-in types that check values but keep names open
The hardest and most transferable decision is the type layer, because the naive version is a trap. It is easy to demand a schema and lock every field name to it. But DynamoDB code uses dot-paths ('address.city'), and people adopt a tool gradually, so a type layer that forbids unknown strings is a type layer nobody can turn on. The goal is narrower and stranger: check the values of fields you know, while still accepting any field name.
The whole thing rests on one type:
type FieldOf<T> = (keyof T & string) | (string & {});
type ValueOf<T, K> = K extends keyof T ? T[K] : unknown;
(string & {}) is the load-bearing trick. It is structurally just string, so any string is assignable, but because it is written as an intersection the compiler keeps the literal members of the union (keyof T) as autocomplete suggestions instead of collapsing them into plain string. You get suggestions for the fields you declared and zero resistance for the ones you did not. ValueOf then types the argument to .eq() or .set() as the field's real type when the field is known and unknown otherwise, so condition('age').eq('old') is a compile error while condition('address.city').eq(x) stays open.
const users = new DDBBuilder<User>('Users');
users.condition('age').gt(18); // ok, age is a number
users.condition('status').eq('banned'); // compile error, not in the union
users.condition('address.city').eq(x); // still fine, names stay open
Two things are worth stealing here. The constraint is T extends object, not T extends Record<string, unknown>, because interfaces have no index signature and would be rejected by the stricter bound, which quietly excludes exactly the interface User {} everyone writes. And the whole layer is opt-in with zero runtime cost: the types erase at compile time, so untyped callers get a plain JS builder and typed callers pay nothing at runtime. The same inference extends to a catalog, where each pattern's parameter type is recovered from the map without annotation:
const users = defineAccessPatterns('Users', {
byId: (b, p) => b.key.hash.name('userId').key.hash.condition.eq(p.id).build('get'),
activeByTenant: (b, p) => b.index('byTenant')
.key.hash.name('tenantId').key.hash.condition.eq(p.tenantId)
.condition('status').eq('active').build('query'),
});
users.byId({ id: 'u-1' }); // param type inferred per pattern
That per-pattern inference is less obvious than it looks, and it carries a lesson worth paying for once. Each entry's parameter type is recovered structurally: a mapped type walks the catalog and pulls P out of every (builder, p: P) => params with a conditional infer, so byId demands { id: string } and activeByTenant demands { tenantId: string } without a single type written twice. The trap I fell into building it is that you cannot also let the caller pass an explicit entity type argument on the same call. The moment someone writes one type parameter by hand, TypeScript stops inferring the others and every pattern's params silently collapse to unknown. Inference is all-or-nothing per call, so the map has to be the only thing being inferred. That rule is invisible until you break it, and it is the kind of thing you want to know before you design an API whose ergonomics depend on inference.
The honest cost of keeping names open: a typo in a field name is not caught, only a mismatch in a known field's value. I chose dot-paths and gradual adoption over catching name typos, and that is a trade, not a free lunch. And once a library leans this hard on types, it has to test them, because a guarantee that is not regression-tested quietly rots. "Passing a banned status is a compile error" stays true only if something checks it, so the type layer is exercised with @ts-expect-error markers that fail the build if the following line does compile. Runtime tests assert what the code does; these assert that the mistakes you promised to catch still refuse to compile, which is the only way a type-level promise stays a promise.
Decision 5: stay at the params layer
The last structural decision is where to sit in the stack. ddbflow emits a plain object and depends on no client, which means the same output feeds AWS SDK v2 and v3 alike, carries zero runtime dependencies, weighs about five kilobytes, and survives the SDK churn that dates v3-only tools. What you give up is the "and I will execute it for you" convenience an ODM provides. For a wedge whose entire pitch is "keep your own client," staying below the client is the coherent choice, not a limitation to apologize for. The small elegant payoff of living at this layer is that conditional queries stay declarative: .when(cond, b => ...) parametrizes which conditions apply, not just their values, so a search endpoint with optional filters never degrades into imperative branching.
The sign the abstraction was right
There is a tell for whether you picked the right core, and it is how you grade a design after the fact. Once an access pattern is a first-class value, a function from parameters to a ready operation, the multi-item helpers stop being separate features and become higher-order operations over that value: a transaction is a list of built operations submitted atomically, pagination is one pattern plus a cursor loop re-feeding LastEvaluatedKey, a batch is the same list with different limits. I did not design those so much as notice they were already implied and write the thin layer that exposed them. When new requirements compose out of the existing abstraction instead of bolting onto its side, the abstraction was carved at the right joint. When every feature needs a new concept, it was not.
What survives the package
The package is downstream of the ideas, and the ideas are the part I would defend: organize around access-patterns-as-parametrized-queries rather than entities; emit whole operations so the tool knows enough to help; single-source the field names; make types opt-in and value-focused so people can actually adopt them; and stay at the params layer for longevity. A short note on timing, since honesty is the theme: the first version of this is from 2021, when SDK v3 was months old and today's type-safe builders did not exist, so the itch genuinely predates most of the alternatives it now sits beside, and several of them have since done parts of it better than I did. That is fine, and it is the tell that the design mattered more than the code. If you are doing serious multi-entity modeling, reach for ElectroDB or DynamoDB-Toolbox; if you want an ODM that owns persistence, Dynamoose. ddbflow is my answer for the case where you keep your client and just want the ceremony gone, and it is honest about stepping aside everywhere else.