A type-safe, functional TypeScript library for AWS DynamoDB with first-class Single Table Design support. Designed to make DynamoDB development elegant, productive, and predictable.
- π Type-Safe - Full TypeScript support with end-to-end type inference
- π― Single Table Design - Built-in support for DynamoDB best practices
- π Functional API - Chainable, composable operations
- β‘οΈ Auto-generated IDs - ULID generation for unique identifiers
- π Automatic Timestamps - Auto-manage
createdAtandupdatedAt - π Transactions - Atomic operations with
TransactWritesupport - π¦ Batch Operations - Efficient
BatchGetandBatchWrite - π¨ Query Builder - Intuitive API for complex queries
- β Validation - Built-in Zod schema validation
- π§ͺ Testable - Easy to mock and test
npm install @ftschopp/dynatable-core
# or
yarn add @ftschopp/dynatable-core
# or
pnpm add @ftschopp/dynatable-coreimport { Table } from '@ftschopp/dynatable-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// Define your schema
const schema = {
format: 'dynatable:1.0.0',
version: '1.0.0',
indexes: {
primary: { hash: 'PK', sort: 'SK' },
gsi1: { hash: 'GSI1PK', sort: 'GSI1SK' },
},
models: {
User: {
key: {
PK: { type: String, value: 'USER#${username}' },
SK: { type: String, value: 'USER#${username}' },
},
attributes: {
username: { type: String, required: true },
name: { type: String, required: true },
email: { type: String, required: true },
followerCount: { type: Number, default: 0 },
},
},
},
params: {
timestamps: true,
},
} as const;
// Create table instance
const table = new Table({
name: 'MyTable',
client: new DynamoDBClient({ region: 'us-east-1' }),
schema,
});
// Use it!
async function example() {
// Create
const user = await table.entities.User.put({
username: 'alice',
name: 'Alice Smith',
email: 'alice@example.com',
})
.ifNotExists()
.execute();
// Read
const retrieved = await table.entities.User.get({
username: 'alice',
}).execute();
// Update
await table.entities.User.update({
username: 'alice',
})
.set('name', 'Alice Johnson')
.add('followerCount', 1)
.returning('ALL_NEW')
.execute();
// Query
const users = await table.entities.User.query()
.where((attr, op) => op.eq(attr.username, 'alice'))
.execute();
// Delete
await table.entities.User.delete({
username: 'alice',
}).execute();
}Dynatable provides full TypeScript support with AWS SDK types for maximum type safety:
All entity operations (put, update, delete, get) have typed dbParams() methods:
// dbParams() returns PutCommandInput from @aws-sdk/lib-dynamodb
const params = table.entities.User.put({ username: 'alice', name: 'Alice' }).dbParams();
// params: PutCommandInput
// Update operations return UpdateCommandInput
const updateParams = table.entities.User.update({ username: 'alice' })
.set('name', 'Bob')
.dbParams();
// updateParams: UpdateCommandInputTransaction builders accept typed parameters from AWS SDK:
import type { TransactPutParams, TransactUpdateParams } from '@ftschopp/dynatable-core';
await table
.transactWrite()
.addPut(table.entities.Like.put({ photoId: '123', likingUsername: 'alice' }).dbParams())
.addUpdate(
table.entities.Photo.update({ username: 'bob', photoId: '123' }).add('likesCount', 1).dbParams()
)
.execute();TypeScript validates all parameters at compile time, catching errors before runtime.
Full documentation is available at the docs site:
Check out complete examples:
- Blog System - Users, posts, comments, and tags
- Instagram Clone - Photos, likes, comments, and followers
This is a Turborepo monorepo with the following structure:
dynatable/
βββ apps/
β βββ crud/ # Example CRUD application
β βββ docs/ # Docusaurus documentation site
β βββ migrations-playground/ # Sandbox for migration scenarios
βββ packages/
β βββ core/ # Main Dynatable library
β βββ migrations/ # DynamoDB migration CLI
β βββ eslint-config/ # Shared ESLint configuration
β βββ typescript-config/ # Shared TypeScript configuration
@ftschopp/dynatable-core- The main Dynatable library with full type-safe DynamoDB operations@ftschopp/dynatable-migrations- DynamoDB migration tool with schema versioning and single table design support@repo/eslint-config- Shared ESLint configurations for all packages@repo/typescript-config- Shared TypeScript configurations for all packages
crud- Example CRUD application demonstrating Dynatable usagedocs- Docusaurus documentation site with examples and guidesmigrations-playground- Sandbox app for exercising the migrations CLI
- Node.js >= 22
- Yarn 1.22.22
# Clone the repository
git clone <repository-url>
cd dynatable
# Install dependencies
yarn install
# Build all packages
yarn build# Development
yarn dev # Start development for all apps
yarn debug # Start development for CRUD app only
# Build
yarn build # Build all packages and apps
# Testing
yarn test # Run tests across all packages
# Linting & Formatting
yarn lint # Lint all packages
yarn format # Format code with Prettier
yarn check-types # Type check all packages
# Clean
yarn clean # Remove all node_modules and build artifactscd apps/docs
npm run start # Start development server
npm run build # Build for productioncd apps/crud
npm run dev # Start with hot reloadDynatable is built around DynamoDB's Single Table Design pattern, where all entities live in one table:
// Different entities, same table
table.entities.User.put({ username: 'alice', name: 'Alice' });
table.entities.Post.put({ userId: 'alice', postId: 'post1', title: 'Hello' });
table.entities.Comment.put({
postId: 'post1',
commentId: 'c1',
content: 'Great!',
});Full TypeScript inference throughout:
const user = await table.entities.User.get({ username: 'alice' }).execute();
// user is typed as: User | undefined
user.username; // β
Type: string
user.name; // β
Type: string
user.email; // β
Type: string
user.invalid; // β TypeScript errorAtomic operations across multiple items:
await table
.transactWrite()
.addPut(
table.entities.Like.put({
photoId: 'photo1',
username: 'alice',
})
.ifNotExists()
.dbParams()
)
.addUpdate(
table.entities.Photo.update({
photoId: 'photo1',
})
.add('likesCount', 1)
.dbParams()
)
.execute();Fluent, type-safe query API:
const photos = await table.entities.Photo.query()
.where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.gt(attr.likesCount, 10)))
.limit(20)
.scanIndexForward(false)
.execute();Built-in pagination support:
const page1 = await table.entities.Post.query()
.where((attr, op) => op.eq(attr.userId, 'alice'))
.limit(20)
.executeWithPagination();
// Next page
if (page1.lastEvaluatedKey) {
const page2 = await table.entities.Post.query()
.where((attr, op) => op.eq(attr.userId, 'alice'))
.startFrom(page1.lastEvaluatedKey)
.limit(20)
.executeWithPagination();
}The library includes comprehensive test coverage:
# Run all tests
yarn test
# Run tests for core package only
cd packages/core
yarn testExample test:
import { describe, it, expect } from '@jest/globals';
describe('User operations', () => {
it('should create a user', async () => {
const user = await table.entities.User.put({
username: 'testuser',
name: 'Test User',
email: 'test@example.com',
}).execute();
expect(user.username).toBe('testuser');
expect(user.createdAt).toBeDefined();
});
});const photo = await table.entities.Photo.put({
username: 'alice',
url: 'https://example.com/photo.jpg',
}).execute();
console.log(photo.photoId); // Auto-generated ULID: 01HZXY9K2M3N4P5Q6R7S8T9V0Wconst user = await table.entities.User.put({
username: 'alice',
name: 'Alice',
}).execute();
console.log(user.createdAt); // ISO 8601 string: '2025-01-15T10:30:00.000Z'
console.log(user.updatedAt); // ISO 8601 string: '2025-01-15T10:30:00.000Z'
// Update automatically refreshes updatedAt
await table.entities.User.update({ username: 'alice' }).set('name', 'Alice Smith').execute();
// updatedAt is now: '2025-01-15T10:35:00.000Z'// Only create if doesn't exist
await table.entities.User.put({
username: 'alice',
name: 'Alice',
})
.ifNotExists()
.execute();
// Only update if condition is met
await table.entities.Post.update({
postId: 'post1',
})
.set('published', true)
.where((attr, op) => op.eq(attr.status, 'draft'))
.execute();// Batch Get
const users = await table.entities.User.batchGet([
{ username: 'alice' },
{ username: 'bob' },
{ username: 'charlie' },
]).execute();
// Batch Write
await table.entities.User.batchWrite([
{ username: 'alice', name: 'Alice', email: 'alice@example.com' },
{ username: 'bob', name: 'Bob', email: 'bob@example.com' },
]).execute();When your model defines GSI key templates, query a GSI by referencing your business attributes β Dynatable resolves them to the GSI keys automatically:
// Schema:
// models.Post.index = {
// GSI1PK: { type: String, value: 'STATUS#${status}' },
// GSI1SK: { type: String, value: 'POST#${postId}' },
// }
const publishedPosts = await table.entities.Post.query()
.where((attr, op) => op.eq(attr.status, 'published'))
.useIndex('gsi1')
.execute();// Scan entire table with filter
const activeUsers = await table.entities.User.scan()
.filter((attr, op) => op.gt(attr.followerCount, 1000))
.limit(50)
.execute();
// Scan with pagination
const page = await table.entities.User.scan().limit(20).executeWithPagination();Dynatable provides a comprehensive set of operators for building complex conditions:
// Exists/Not Exists
await table.entities.User.update({ username: 'alice' })
.set('email', 'alice@example.com')
.where((attr, op) => op.notExists(attr.email))
.execute();
// Contains (for strings, sets, lists)
const users = await table.entities.User.query()
.where((attr, op) => op.and(op.eq(attr.username, 'alice'), op.contains(attr.tags, 'premium')))
.execute();
// IN operator
const users = await table.entities.User.scan()
.filter((attr, op) => op.in(attr.status, ['active', 'pending', 'verified']))
.execute();
// Size function (for strings, sets, lists, maps)
const posts = await table.entities.Post.query()
.where((attr, op) =>
op.and(
op.eq(attr.userId, 'alice'),
op.size(attr.tags).gte(3) // Posts with 3+ tags
)
)
.execute();
// Attribute type checking
await table.entities.User.scan()
.filter((attr, op) => op.attributeType(attr.metadata, 'M')) // Type is Map
.execute();Track and debug your DynamoDB operations with the built-in logger:
import { createDynamoDBLogger } from '@ftschopp/dynatable-core';
// Create a logger
const logger = createDynamoDBLogger({
enabled: true, // Enable/disable logging
logParams: true, // Log request parameters
logResponse: false, // Log responses (can be verbose)
});
// Use with Table
const table = new Table({
name: 'MyTable',
client,
schema,
logger, // Attach the logger
});
// Now all operations are logged to console
await table.entities.User.get({ username: 'alice' }).execute();
// Logs: [DynamoDB] GetCommand { TableName: 'MyTable', Key: { PK: 'USER#alice', SK: 'USER#alice' } }Extend Dynatable with custom middleware for request/response transformation:
// Middleware to clean internal keys from responses
const cleanKeysMiddleware = {
response: (item: any) => {
const { PK, SK, GSI1PK, GSI1SK, ...cleaned } = item;
return cleaned;
},
};
// Middleware is automatically applied based on schema params
// Set cleanInternalKeys: true in schema params to enable built-in middlewareAtomic reads across multiple items:
// Read multiple items atomically β execute() resolves to a flat array
// of items in the same order as the addGet() calls.
const [user, photo, like] = await table
.transactGet()
.addGet(table.entities.User.get({ username: 'alice' }).dbParams())
.addGet(table.entities.Photo.get({ username: 'alice', photoId: 'photo1' }).dbParams())
.addGet(table.entities.Like.get({ photoId: 'photo1', likingUsername: 'bob' }).dbParams())
.execute();import {
Table, // Main Table class
type SchemaDefinition, // Schema type definition
type ModelDefinition, // Model type definition
type InferModel, // Infer model type from definition
type InferInput, // Infer input type from definition
type InferKeyInput, // Infer key input type
type InferModelFromSchema, // Infer model type from full schema
type InferInputFromSchema, // Infer input type from full schema
createDynamoDBLogger, // Create logger instance
type DynamoDBLogger, // Logger type
type DynamoDBLoggerConfig, // Logger config type
} from '@ftschopp/dynatable-core';Each entity provides the following builder methods:
get(key)βGetBuilder- Retrieve a single item by keyput(item)βPutBuilder- Insert or replace an itemupdate(key)βUpdateBuilder- Update specific attributesdelete(key)βDeleteBuilder- Remove an itemquery()βQueryBuilder- Query items with key conditionsscan()βScanBuilder- Scan table/index with filtersbatchGet(keys[])βBatchGetBuilder- Retrieve up to 100 itemsbatchWrite(items[])βBatchWriteBuilder- Write up to 25 items
Table-level operations:
table.transactWrite()βTransactWriteBuilder- Atomic write operationstable.transactGet()βTransactGetBuilder- Atomic read operations
eq(attr, value)- Equals (=)ne(attr, value)- Not equals (<>)lt(attr, value)- Less than (<)lte(attr, value)- Less than or equal (<=)gt(attr, value)- Greater than (>)gte(attr, value)- Greater than or equal (>=)between(attr, low, high)- Between two values
beginsWith(attr, prefix)- Begins with a string prefixcontains(attr, value)- Contains a substring/value (works with strings, sets, lists)
exists(attr)- Attribute existsnotExists(attr)- Attribute does not exist
attributeType(attr, type)- Check attribute type- Types:
'S'(String),'N'(Number),'B'(Binary),'SS'(String Set),'NS'(Number Set),'BS'(Binary Set),'M'(Map),'L'(List),'NULL','BOOL'
- Types:
in(attr, values[])- Value is in arraysize(attr)- Get size of attribute, returnsSizeRefwith comparison methods:.eq(n),.ne(n),.lt(n),.lte(n),.gt(n),.gte(n)
and(...conditions)- Combine conditions with ANDor(...conditions)- Combine conditions with ORnot(condition)- Negate a condition
.select(attributes?: string[]) // Project specific attributes
.consistentRead(enabled?: boolean) // Enable consistent read
.dbParams() // Get raw DynamoDB parameters
.execute() // Execute and return item.ifNotExists() // Only put if item doesn't exist
.returning(value: 'NONE' | 'ALL_OLD') // Return values
.where(condition) // Conditional expression
.dbParams() // Get raw parameters
.execute() // Execute operation.set(attr, value) // Set attribute value
.remove(attr) // Remove attribute
.add(attr, value) // Add to number/set
.delete(attr, value) // Delete from set
.returning('NONE' | 'ALL_OLD' | 'UPDATED_OLD' | 'ALL_NEW' | 'UPDATED_NEW')
.where(condition) // Conditional expression
.dbParams() // Get raw parameters
.execute() // Execute operation.returning(value: 'NONE' | 'ALL_OLD') // Return deleted item
.where(condition) // Conditional expression
.dbParams() // Get raw parameters
.execute() // Execute operation.where(condition) // Key condition + filter expression
.limit(count) // Max items to return
.scanIndexForward(forward: boolean) // Sort order (default: true)
.useIndex(indexName) // Use GSI/LSI
.select(attributes?: string[]) // Project attributes
.consistentRead(enabled?: boolean) // Consistent read
.startFrom(lastKey) // Pagination cursor
.execute() // Execute and return items
.executeWithPagination() // Execute and return { items, lastEvaluatedKey }.where(condition) // Filter expression
.limit(count) // Max items to return
.select(attributes?: string[]) // Project attributes
.consistentRead(enabled?: boolean) // Consistent read
.startFrom(lastKey) // Pagination cursor
.execute() // Execute and return items
.executeWithPagination() // Execute and return { items, lastEvaluatedKey }.select(attributes?: string[]) // Project attributes
.consistentRead(enabled?: boolean) // Consistent read
.execute() // Execute and return items array.execute() // Execute batch write.addPut(params) // Add put operation
.addUpdate(params) // Add update operation
.addDelete(params) // Add delete operation
.addConditionCheck(params) // Add condition check
.withClientRequestToken(token) // Idempotency token
.execute() // Execute transaction.addGet(params) // Add get operation
.execute() // Execute transaction, returns { items }const schema = {
format: 'dynatable:1.0.0', // Schema format version
version: '1.0.0', // Your schema version
indexes: {
primary: {
hash: 'PK', // Partition key name
sort: 'SK', // Sort key name (optional)
},
gsi1: {
// Global Secondary Index
hash: 'GSI1PK',
sort: 'GSI1SK',
},
},
models: {
User: {
key: {
// Primary key definition
PK: {
type: String,
value: 'USER#${username}', // Template with variables
},
SK: {
type: String,
value: 'USER#${username}',
},
},
index: {
// GSI key definition (optional)
GSI1PK: { type: String, value: 'USER' },
GSI1SK: { type: String, value: 'USER#${username}' },
},
attributes: {
username: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
email: {
type: String,
},
age: {
type: Number,
default: 0,
},
userId: {
type: String,
generate: 'ulid', // Auto-generate ULID
},
tags: {
type: Array,
},
},
},
},
params: {
timestamps: true, // Auto createdAt/updatedAt as ISO 8601 strings
cleanInternalKeys: false, // Hide PK/SK in results
},
} as const;// Preferred: schema-driven inference (honors params.timestamps β
// includes createdAt/updatedAt automatically when enabled)
type User = InferModelFromSchema<typeof schema, 'User'>;
type UserInput = InferInputFromSchema<typeof schema, 'User'>;
// Low-level alternatives that take a ModelDefinition directly.
// Use these only when you don't have the surrounding schema in scope
// (e.g. utilities that consume models). They do NOT see params.timestamps.
type UserModel = InferModel<typeof schema.models.User>;
type UserInputRaw = InferInput<typeof schema.models.User>;
// Infer key input type (only key template variables)
type UserKey = InferKeyInput<typeof schema.models.User>;Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License.
- Built with TypeScript
- Uses Zod for validation
- Uses ULID for ID generation
- Powered by AWS SDK for JavaScript v3
- AWS DynamoDB Best Practices
- The DynamoDB Book by Alex DeBrie
- Single Table Design with DynamoDB
- AWS re:Invent - Advanced Design Patterns for DynamoDB
Made with β€οΈ for the DynamoDB community