Skip to content

Commit

Permalink
feat: add type-safe mappings for marshall and unmarshall from util-dy…
Browse files Browse the repository at this point in the history
…namodb (#19)
  • Loading branch information
sam authored Feb 18, 2022
1 parent ed48689 commit 98c22b7
Show file tree
Hide file tree
Showing 12 changed files with 283 additions and 5 deletions.
4 changes: 4 additions & 0 deletions .projen/deps.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .projen/tasks.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .projenrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ const project = new typescript.TypeScriptProject({
deps: [
"aws-sdk",
"@aws-sdk/client-dynamodb",
"@aws-sdk/util-dynamodb",
"@aws-sdk/smithy-client",
"@aws-sdk/types",
"@types/aws-lambda",
],
eslintOptions: {
ignorePatterns: ["**"],
},
tsconfig: {
compilerOptions: {
lib: ["dom"],
},
},
gitignore: [".DS_Store"],
releaseToNpm: true,
});
Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,89 @@ If you add a `ConditionExpression` in `putItem`, you will be prompted for any `#
Same is true for a `query`:

![typesafe query KeyConditionExpression and Filter](img/query-expression.gif)

### Marshall a JS Object to an AttributeMap

A better type definition `@aws-sdk/util-dynamodb`'s `marshall` function is provided which maintains the typing information of the value passed to it and also adapts the output type based on the input `marshallOptions`

Given an object literal:

```ts
const myObject = {
key: "key",
sort: 123,
binary: new Uint16Array([1]),
buffer: Buffer.from("buffer", "utf8"),
optional: 456,
list: ["hello", "world"],
record: {
key: "nested key",
sort: 789,
},
} as const;
```

Call the `marshall` function to convert it to an AttributeMap which maintains the exact structure in the type system:

```ts
import { marshall } from "typesafe-dynamodb/lib/marshall";
// marshall the above JS object to its corresponding AttributeMap
const marshalled = marshall(myObject)

// typing information is carried across exactly, including literal types
const marshalled: {
readonly key: S<"key">;
readonly sort: N<123>;
readonly binary: B;
readonly buffer: B;
readonly optional: N<456>;
readonly list: L<readonly [S<"hello">, S<"world">]>;
readonly record: M<...>;
}
```
### Unmarshall an AttributeMap back to a JS Object
A better type definition `@aws-sdk/util-dynamodb`'s `unmarshall` function is provided which maintains the typing information of the value passed to it and also adapts the output type based on the input `unmarshallOptions`.
```ts
import { unmarshall } from "typesafe-dynamodb/lib/marshall";

// unmarshall the AttributeMap back into the original object
const unmarshalled = unmarshall(marshalled);

// it maintains the literal typing information (as much as possible)
const unmarshalled: {
readonly key: "key";
readonly sort: 123;
readonly binary: NativeBinaryAttribute;
readonly buffer: NativeBinaryAttribute;
readonly optional: 456;
readonly list: readonly [...];
readonly record: Unmarshall<...>;
}
```
If you specify `{wrapNumbers: true}`, then all `number` types will be wrapped as `{ value: string }`:
```ts
const unmarshalled = unmarshall(marshalled, {
wrapNumbers: true,
});

// numbers are wrapped in { value: string } because of `wrappedNumbers: true`
unmarshalled.sort.value; // string

// it maintains the literal typing information (as much as possible)
const unmarshalled: {
readonly key: "key";
// notice how the number is wrapped in the `NumberValue` type?
// this is because `wrapNumbers: true`
readonly sort: NumberValue<123>;
readonly binary: NativeBinaryAttribute;
readonly buffer: NativeBinaryAttribute;
readonly optional: NumberValue<...>;
readonly list: readonly [...];
readonly record: Unmarshall<...>;
};
```
1 change: 1 addition & 0 deletions package.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion src/attribute-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ export type AttributeValue =
| L<ArrayLike<AttributeValue>>
| M<Record<string, AttributeValue>>;

export type AttributeMap = Record<string, AttributeValue>;

export type NativeBinaryAttribute =
| ArrayBuffer
| BigInt64Array
| BigUint64Array
| Buffer
| DataView
| Float32Array
| Float64Array
| Int16Array
| Int32Array
| Int8Array
| Uint16Array
| Uint32Array
| Uint8Array
| Uint8ClampedArray;

export type ToAttributeMap<T extends object> = ToAttributeValue<T>["M"];

/**
Expand All @@ -23,8 +41,12 @@ export type ToAttributeValue<T> = T extends undefined
? S<T>
: T extends number
? N<T>
: T extends Date
: // this behavior is not defined by https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html
// should it be a number of string?
T extends Date
? N<number>
: T extends NativeBinaryAttribute
? B
: T extends ArrayLike<unknown>
? L<{
[i in keyof T]: i extends "length" ? T[i] : ToAttributeValue<T[i]>;
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
export * from "./attribute-value";
66 changes: 66 additions & 0 deletions src/marshall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
marshallOptions,
unmarshallOptions,
marshall as _marshall,
unmarshall as _unmarshall,
} from "@aws-sdk/util-dynamodb";
import {
AttributeMap,
B,
L,
M,
N,
NativeBinaryAttribute,
S,
ToAttributeMap,
} from "./attribute-value";

export const marshall: <
Item extends object,
MarshallOptions extends marshallOptions | undefined
>(
item: Item,
options?: MarshallOptions
) => ToAttributeMap<Item> = _marshall;

export const unmarshall: <
Item extends AttributeMap,
UnmarshallOptions extends unmarshallOptions | undefined
>(
item: Item,
options?: UnmarshallOptions
) => {
[prop in keyof Item]: Unmarshall<Item[prop], UnmarshallOptions>;
} = _unmarshall as any;

export interface NumberValue<N extends number> {
value: `${N}`;
}

export type Unmarshall<
T,
UnmarshallOptions extends unmarshallOptions | undefined
> = T extends S<infer s>
? s
: T extends B
? NativeBinaryAttribute
: T extends N<infer n>
? Exclude<UnmarshallOptions, undefined>["wrapNumbers"] extends true
? NumberValue<n>
: n
: T extends Date
? string
: T extends L<infer Items>
? {
[i in keyof Items]: i extends "length"
? Items[i]
: Unmarshall<Items[i], UnmarshallOptions>;
}
: T extends M<infer Attributes>
? {
[prop in keyof Attributes]: Unmarshall<
Attributes[prop],
UnmarshallOptions
>;
}
: never;
87 changes: 87 additions & 0 deletions test/marshall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import "jest";

import { marshall, unmarshall } from "../src/marshall";

const myObject = {
key: "key",
sort: 123,
binary: new Uint16Array([1]),
buffer: Buffer.from("buffer", "utf8"),
optional: 456,
list: ["hello", "world"],
record: {
key: "nested key",
sort: 789,
},
} as const;

test("should marshall MyItem to ToAttributeMap<MyItem>", () => {
const marshalled = marshall(myObject);

marshalled.key.S;
marshalled.sort.N;
marshalled.binary?.B;
marshalled.buffer?.B;
marshalled.optional?.N;
marshalled.list?.L[0].S;
marshalled.list?.L[1].S;
// @ts-expect-error
marshalled.list?.L[2]?.S;
marshalled.record.M.key.S;
marshalled.record.M.sort.N;
});

test("should unmarshall MyItem from ToAttributeMap<MyItem>", () => {
const marshalled = marshall(myObject);
const unmarshalled = unmarshall(marshalled);

expect(unmarshalled).toEqual(myObject);

unmarshalled.key;
unmarshalled.sort;
unmarshalled.binary;
unmarshalled.buffer;
unmarshalled.optional.toString(10); // is a number
unmarshalled.list?.[0];
unmarshalled.list?.[1];
// @ts-expect-error
unmarshalled.list?.[2];
unmarshalled.record.key;
unmarshalled.record.sort.toString(10); // is a number
});

test("unmarshall should map numbers to string when wrapNumbers: true", () => {
const marshalled = marshall(myObject);
const unmarshalled = unmarshall(marshalled, {
wrapNumbers: true,
});

const expected: typeof unmarshalled = {
...myObject,
sort: {
value: "123",
},
optional: {
value: "456",
},
record: {
key: "nested key",
sort: {
value: "789",
},
},
};
expect(unmarshalled).toEqual(expected);

unmarshalled.key;
unmarshalled.sort.value; // wrapped NumberValue
unmarshalled.binary;
unmarshalled.buffer;
unmarshalled.optional?.value; // wrapped NumberValue
unmarshalled.list?.[0];
unmarshalled.list?.[1];
// @ts-expect-error
unmarshalled.list?.[2];
unmarshalled.record.key;
unmarshalled.record.sort;
});
2 changes: 1 addition & 1 deletion tsconfig.dev.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tsconfig.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 98c22b7

Please sign in to comment.