@leandroluk/dynamoose-typed
v1.8.1
Published
A strongly-typed, decorator-driven ODM wrapper around Dynamoose
Maintainers
Readme
A strongly-typed, decorator-driven wrapper around Dynamoose v4 — the DynamoDB ODM for Node.js.
Why this exists
Dynamoose is a great library, but its TypeScript story is painfully lacking. The return types of model() are overloaded to the point where nothing is inferred correctly, every operation resolves to any, and the schema definition is a plain object with no type-safety. You end up fighting the type system instead of relying on it.
dynamoose-typed wraps Dynamoose behind a fully-typed API that mirrors TypeORM's DataSource / Repository / EntityManager pattern. Define your tables as decorated classes, let the library wire up the schema, and get proper types everywhere.
Features
- Decorator-based schema definition (
@DynamoTable,@StringAttribute,@NestedAttribute, …) - Typed
Repository<T>andEntityManagerfor all CRUD operations - Automatic
created_at/updated_at/ soft-delete (deleted_at) timestamps - GSI support via
index: trueorindex: { name, rangeKey, project }on any attribute decorator count()sparse-GSI optimization — twoSelect: COUNTscans instead of fetching item bodies- Atomic transactions via
dataSource.transaction() - Batch operations (
batchSave,batchGet,batchDelete) InMemoryDataSourcefor fast, zero-infrastructure unit tests- Global table name prefix/suffix — isolate environments via
DataSourceOptions.table - Throughput configuration —
ON_DEMANDor provisioned capacity via@DynamoTableorDataSourceOptions.table - 100% statement / branch / function coverage
Requirements
- Node.js ≥ 22
- TypeScript with
experimentalDecorators: trueandemitDecoratorMetadata: true
Get started
pnpm add dynamoose-typed dynamoose @aws-sdk/client-dynamodbtsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true
}
}Defining a table
import { DynamoTable, StringAttribute, NumberAttribute, CreateDateAttribute, UpdateDateAttribute, DeleteDateAttribute } from 'dynamoose-typed';
import crypto from 'node:crypto';
@DynamoTable('users', {
hooks: {
beforeInsert: (item) => console.log('inserting', item),
},
})
class UserTable {
@StringAttribute({ hashKey: true, default: crypto.randomUUID })
id!: string;
@StringAttribute({ required: true })
name!: string;
@NumberAttribute({ default: 0 })
age!: number;
@CreateDateAttribute('created_at')
createdAt!: Date;
@UpdateDateAttribute('updated_at')
updatedAt!: Date;
@DeleteDateAttribute('deleted_at')
deletedAt!: Date | null;
}Nested documents
import { DynamoDocument, StringAttribute, NestedAttribute } from 'dynamoose-typed';
import crypto from 'node:crypto';
@DynamoDocument()
class AddressDocument {
@StringAttribute({ required: true })
street!: string;
@StringAttribute({ required: true })
city!: string;
}
@DynamoTable('orders')
class OrderTable {
@StringAttribute({ hashKey: true, default: crypto.randomUUID })
id!: string;
@NestedAttribute(() => AddressDocument)
address!: AddressDocument;
@ArrayAttribute(() => String, { default: () => [] })
tags!: string[];
@SetAttribute(() => String)
roles!: Set<string>;
}DataSource
import { DataSource, DynamoDB } from 'dynamoose-typed';
const dataSource = new DataSource({
entities: [UserTable, OrderTable],
client: new DynamoDB({ region: 'us-east-1' }),
});
await dataSource.initialize();For DynamoDB Local:
const dataSource = new DataSource({
entities: [UserTable],
local: { host: 'localhost', port: 8000 },
});
await dataSource.initialize();Pinging connection
You can verify the connectivity and credentials configuration at any time without accessing a specific table by calling ping():
const isHealthy = await dataSource.ping();InMemoryDataSource also implements this method and always returns true to ensure API parity in tests.
Table name prefix/suffix
When multiple environments share a single DynamoDB account, you can isolate them using a table name prefix or suffix rather than separate regions:
// production: @DynamoTable('users') → 'prod_users'
const dataSource = new DataSource({
entities: [UserTable, OrderTable],
client: new DynamoDB({ region: 'us-east-1' }),
table: { prefix: 'prod_' },
});
// staging: @DynamoTable('users') → 'staging_users'
const stagingDs = new DataSource({
entities: [UserTable],
client: new DynamoDB({ region: 'us-east-1' }),
table: { prefix: 'staging_' },
});
// both prefix and suffix: @DynamoTable('users') → 'prod_users_v2'
const ds = new DataSource({
entities: [UserTable],
client: new DynamoDB({ region: 'us-east-1' }),
table: { prefix: 'prod_', suffix: '_v2' },
});Works with InMemoryDataSource too — useful for verifying naming conventions in tests:
const ds = new InMemoryDataSource({
entities: [UserTable],
table: { prefix: 'prod_' },
});Throughput configuration
Set the DynamoDB billing mode per table or globally:
// per-table — ON_DEMAND (pay-per-request)
@DynamoTable('users', { throughput: 'ON_DEMAND' })
class UserTable { ... }
// per-table — provisioned with separate read/write
@DynamoTable('orders', { throughput: { read: 10, write: 5 } })
class OrderTable { ... }
// global default for all tables (overridden per-table if set)
const dataSource = new DataSource({
entities: [UserTable, OrderTable],
client: new DynamoDB({ region: 'us-east-1' }),
table: { throughput: 'ON_DEMAND' },
});Per-table throughput takes precedence over the global default. When neither is set, Dynamoose's default applies (read: 5, write: 5).
Repository
const userRepository = dataSource.getRepository(UserTable);
// create (no persistence)
const newUser = userRepository.create({ name: 'Alice', age: 30 });
// save
const savedUser = await userRepository.save(newUser);
// find by key
const foundUser = await userRepository.findOneBy({ id: savedUser.id });
// find by key — throws if not found or soft-deleted
const user = await userRepository.findOneByOrFail({ id: '123' });
// include soft-deleted items
const deletedUser = await userRepository.findOneBy({ id: '123' }, { withDeleted: true });
// query by hash key
const { items, count, lastKey } = await userRepository.find('alice-partition', {
limit: 20,
consistent: true,
startAt: lastKey,
});
// query by hash key + sort key condition (requires rangeKey on table)
const { items: recent } = await userRepository.find('user-1', {
sortKey: { between: ['2024-01', '2024-12'] },
});
const { items: after } = await userRepository.find('user-1', {
sortKey: { beginsWith: '2024-' },
});
// full-table scan
const { items: allUsers } = await userRepository.scan({ withDeleted: false });
// query by GSI (requires index: true on the attribute)
const { items: byEmail } = await userRepository.findByIndex('email', '[email protected]');
// count
const total = await userRepository.count();
// soft-delete (sets deleted_at) — falls back to hardDelete if no @DeleteDateAttribute
await userRepository.delete({ id: savedUser.id });
// hard delete
await userRepository.hardDelete({ id: savedUser.id });
// restore soft-deleted item
await userRepository.restore({ id: savedUser.id });
// batch operations
await userRepository.batchSave([user1, user2, user3]);
await userRepository.batchDelete([{ id: '1' }, { id: '2' }]);
const users = await userRepository.batchGet([{ id: '1' }, { id: '2' }]);EntityManager
Access via dataSource.manager to work with multiple entities without creating a repo for each:
const manager = dataSource.manager;
const user = await manager.findOneByOrFail(UserTable, { id: '1' });
const order = await manager.findOneByOrFail(OrderTable, { id: 'o1' });
await manager.save(user);
await manager.delete(OrderTable, { id: 'o1' });Transactions
Reads inside the callback execute immediately. Writes are collected and flushed atomically via dynamoose.transaction() when the callback resolves. If the callback throws, no writes are flushed.
await dataSource.transaction(async (tx) => {
const user = await tx.findOneByOrFail(UserTable, { id: '1' });
user.name = 'Updated';
await tx.save(user); // enqueued
await tx.delete(OrderTable, { id: 'o1' }); // enqueued
});
// both writes committed atomically hereDynamoDB limits: max 100 items per transaction, same-region only.
Attribute decorators reference
| Decorator | DynamoDB type | Notes |
| ----------------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| @StringAttribute | S | Supports hashKey, rangeKey, minLength, maxLength, trim, lowercase, uppercase |
| @NumberAttribute | N | Supports min, max |
| @BooleanAttribute | BOOL | |
| @DateAttribute | S / N | format: 'epoch' (default) or 'iso'; ttl: true stores epoch seconds with auto transforms |
| @CreateDateAttribute | S / N | Set once on insert, never updated; format: 'epoch' (default) or 'iso' |
| @UpdateDateAttribute | S / N | Updated on every save/update; format: 'epoch' (default) or 'iso' |
| @DeleteDateAttribute | S / N | Set by delete(), cleared by restore(); index: true enables sparse-GSI count() optimization |
| @NestedAttribute(() => Doc) | M | Doc must be decorated with @DynamoDocument |
| @ArrayAttribute(() => Type) | L | Primitives or @DynamoDocument instances |
| @SetAttribute(() => Type) | SS / NS | Must be a Set<string> or Set<number> |
| @Attribute(options) | any | Raw Dynamoose attribute passthrough |
All decorators accept an optional first argument alias (string) to map a TypeScript property name to a different DynamoDB attribute name:
@StringAttribute('full_name', { required: true })
fullName!: string;
// stored as "full_name" in DynamoDB, accessed as .fullName in codeAll decorators also accept index: true to create a DynamoDB GSI on that attribute (default name: ${attributeName}GlobalIndex):
@StringAttribute({ index: true })
email!: string; // creates "emailGlobalIndex" GSIGSI indexes
Pass index: true on any attribute decorator to create a DynamoDB GSI:
@DynamoTable('users')
class UserTable {
@StringAttribute({ hashKey: true })
id!: string;
@StringAttribute({ index: true }) // GSI: "emailGlobalIndex"
email!: string;
// sparse GSI on soft-delete — enables count() optimization
@DeleteDateAttribute('deleted_at', { index: true })
deletedAt!: Date | null;
}Use repo.findByIndex(propertyKey, value) to query any GSI. The library maps the TypeScript property key to the DynamoDB attribute name (alias-aware) and derives the index name as ${attrName}GlobalIndex:
const { items } = await userRepository.findByIndex('email', '[email protected]');
const { items: active } = await userRepository.findByIndex('isActive', true, { limit: 50 });When index: true is set on @DeleteDateAttribute, repo.count() uses a two-Select: COUNT strategy instead of scanning item bodies:
// 2× Select:COUNT — total minus GSI count (only soft-deleted items appear in the sparse index)
const activeCount = await repo.count();
// plain Select:COUNT — no soft-delete filter needed
const totalCount = await repo.count({ withDeleted: true });| Condition | Strategy | Item bodies |
| ------------------------------------- | -------------------------------- | ----------- |
| withDeleted: true or no soft-delete | Select: COUNT | No |
| Soft-delete + GSI (index: true) | 2× Select: COUNT (total − GSI) | No |
| Soft-delete, no GSI | Full scan + client-side filter | Yes |
Hooks
Hooks run before/after each write operation. Declare them on @DynamoTable:
@DynamoTable('users', {
hooks: {
beforeInsert: async (item) => { /* validate, enrich */ },
afterInsert: async (item) => { /* emit event */ },
beforeUpdate: async (item) => { /* audit log */ },
afterUpdate: async (item) => { /* cache invalidation */ },
beforeDelete: async (item) => { /* cascade */ },
afterDelete: async (item) => { /* cleanup */ },
},
})
class UserTable { ... }Testing with InMemoryDataSource
No DynamoDB connection, no AWS credentials needed. Drop it in wherever you use DataSource:
import { InMemoryDataSource } from 'dynamoose-typed/testing';
describe('UserService', () => {
let dataSource: InMemoryDataSource;
beforeEach(() => {
dataSource = new InMemoryDataSource({ entities: [UserTable] });
});
it('creates and retrieves a user', async () => {
const repo = dataSource.getRepository(UserTable);
await repo.save({ id: '1', name: 'Alice', age: 30 });
const user = await repo.findOneBy({ id: '1' });
expect(user?.name).toBe('Alice');
});
});InMemoryDataSource exposes the same getRepository, manager, and transaction surface as the real DataSource, so your service code under test doesn't change at all.
Tips
Attribute aliases keep DynamoDB attribute names decoupled from TypeScript property names. Use snake_case attribute names in DynamoDB and camelCase properties in code by passing an alias string as the first argument to any attribute decorator.
Soft deletes are automatic when @DeleteDateAttribute is present. Calling repo.delete() sets the column; repo.restore() clears it. All queries and scans filter out soft-deleted rows by default — pass { withDeleted: true } to include them. Add index: true to enable the sparse-GSI count() optimization and avoid full scans.
repo.delete() falls back to a hard delete when the entity has no @DeleteDateAttribute. No soft-delete column → delete() permanently removes the item, identical to calling hardDelete() directly. Tables that need both behaviors should declare @DeleteDateAttribute and call hardDelete() explicitly when a permanent removal is intended.
getRepository lazy-initializes the DataSource if you haven't called initialize() yet. This is useful for lightweight scripts that don't need an explicit boot sequence.
Timestamp storage format defaults to epoch (milliseconds as Number). Pass { format: 'iso' } to store as ISO-8601 strings instead. The Date native storage type is no longer supported.
DynamoDB transactions have a hard limit of 100 items. If your callback enqueues more than 100 writes, DynamoDB will reject the flush. Split large transactions into smaller chunks.
InMemoryDataSource.clear() resets all in-memory data. Call it in beforeEach to keep tests isolated:
beforeEach(() => dataSource.clear());Utility functions
serializeDynamoTableItem(instance)
Converts a @DynamoTable class instance to a plain object using DynamoDB attribute names (respecting aliases) and serializing Date values to their storage format. Useful for migration scripts, event processors, or any code that needs to convert TypeScript objects to DynamoDB format without a running DataSource.
import { serializeDynamoTableItem } from '@leandroluk/dynamoose-typed';
@DynamoTable('users')
class UserTable {
@StringAttribute({ hashKey: true }) id!: string;
@StringAttribute('full_name') name!: string;
@CreateDateAttribute('created_at') createdAt!: Date;
}
const user = new UserTable();
user.id = '1';
user.name = 'Alice';
user.createdAt = new Date('2024-01-01T00:00:00.000Z');
serializeDynamoTableItem(user);
// → { id: '1', full_name: 'Alice', created_at: 1704067200000 }parseDynamoTableItem(entityClass, raw)
Converts a raw DynamoDB attribute-named object back into a typed @DynamoTable class instance, reversing alias mappings and deserializing stored date values back to Date objects.
import { parseDynamoTableItem } from '@leandroluk/dynamoose-typed';
parseDynamoTableItem(UserTable, { id: '1', full_name: 'Alice', created_at: 1704067200000 });
// → UserTable { id: '1', name: 'Alice', createdAt: Date(2024-01-01) }Both functions handle nested @DynamoDocument objects and arrays of documents recursively. Unknown fields (not declared in the schema) are passed through as-is in both directions.
Support
If you find this project useful, please consider supporting its development:
