Type-safe DynamoDB composite key management for TypeScript.
DynamoDB offers incredible flexibility, but managing advanced patterns and techniques can be a challenge. Rotorise simplifies complex operations by providing abstractions for key definitions, composite key constructors, partial composite keys, and advanced sort key usage in queries. It integrates seamlessly with Brushless for a frictionless and performant DynamoDB experience.
npm install rotoriseDefine your entity type and schema. Rotorise infers the exact key string types.
import { tableEntry } from 'rotorise'
type User = {
orgId: string
id: string
role: 'admin' | 'user' | 'guest'
email: string
}
const UserTable = tableEntry<User>()({
PK: ['orgId', 'id'],
SK: ['role'],
GSI1PK: ['role'],
GSI1SK: 'email',
})Entry point for defining a DynamoDB table schema. Returns an object with the methods below.
The double-call <Entity>()(schema) is required because TypeScript does not support partial type parameter inference — Entity is explicit while Schema is inferred from arguments.
The optional separator defaults to '#'.
Builds a specific key value from the given attributes.
UserTable.key('PK', { orgId: 'acme', id: '123' })
// => 'ORGID#acme#ID#123'
UserTable.key('GSI1SK', { email: 'a@b.com' })
// => 'a@b.com'config options:
depth— Limit composite key to the first N components. Useful forbegins_withqueries.allowPartial— Whentrue, stops building the key when an attribute is missing instead of throwing. Returns a union of all valid partial prefixes at the type level.enforceBoundary— Whentrue, appends a trailing separator if the key is partial. Ensures abegins_withquery doesn't match unintended prefixes.
UserTable.key('PK', { orgId: 'acme' }, { allowPartial: true })
// => 'ORGID#acme'
// Type: 'ORGID#acme' | `ORGID#${string}#ID#${string}`
UserTable.key('PK', { orgId: 'acme', id: '1' }, { depth: 1 })
// => 'ORGID#acme'
UserTable.key('PK', { orgId: 'acme', id: '1' }, { depth: 1, enforceBoundary: true })
// => 'ORGID#acme#'Converts a raw entity into a complete DynamoDB item with all keys computed.
const item = UserTable.toEntry({
orgId: 'acme',
id: '123',
role: 'admin',
email: 'a@b.com',
})
// => { orgId: 'acme', id: '123', role: 'admin', email: 'a@b.com',
// PK: 'ORGID#acme#ID#123', SK: 'ROLE#admin',
// GSI1PK: 'ROLE#admin', GSI1SK: 'a@b.com' }Rejects excess properties at the type level.
Strips computed keys from a table entry, returning the raw entity.
const user = UserTable.fromEntry(item)
// => { orgId: 'acme', id: '123', role: 'admin', email: 'a@b.com' }Zero-runtime inference helper. Use with typeof to get the full table entry type.
type UserEntry = typeof UserTable.inferCreates a proxy that builds DynamoDB expression paths as strings.
UserTable.path().email.toString() // => 'email'
UserTable.path().PK.toString() // => 'PK'Override how a field maps to its key segment using a transform function.
const Table = tableEntry<User>()({
PK: [
['orgId', (id: string) => ({ tag: 'ORG', value: id })],
['id', (id: string) => ({ tag: 'USER', value: id })],
],
SK: ['role'],
})
Table.key('PK', { orgId: 'acme', id: '123' })
// => 'ORG#acme#USER#123'A transform returns either:
- A
joinablevalue (the field name uppercased becomes the tag) { value }(no tag segment emitted){ tag, value }(custom tag)
When your table stores a union of entity types, use a discriminator to define per-variant key specs.
type Item =
| { kind: 'order'; orderId: string; userId: string }
| { kind: 'refund'; refundId: string; orderId: string }
const ItemTable = tableEntry<Item>()({
PK: {
discriminator: 'kind',
spec: {
order: ['userId', 'orderId'],
refund: ['orderId', 'refundId'],
},
},
SK: ['kind'],
})
ItemTable.key('PK', { kind: 'order', userId: 'u1', orderId: 'o1' })
// => 'USERID#u1#ORDERID#o1'
ItemTable.key('PK', { kind: 'refund', orderId: 'o1', refundId: 'r1' })
// => 'ORDERID#o1#REFUNDID#r1'Set a discriminated spec value to null to produce undefined (useful for GSIs that don't apply to all variants).
const Table = tableEntry<User>()(schema, '-')
// Keys use '-' instead of '#': 'ORGID-acme-ID-123'All runtime errors throw RotoriseError (exported), so you can distinguish library errors from your own.
import { RotoriseError } from 'rotorise'
try {
Table.key('PK', { /* missing required attrs */ })
} catch (e) {
if (e instanceof RotoriseError) { /* ... */ }
}Open an issue or a PR. We are open to any kind of contribution and feedback.