DynamoDB
TypeScript
AWS
Database
Debugging

DynamoDB get item TypeScript hell

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

Using DynamoDB's GetItem with TypeScript is frustrating because the AWS SDK returns items as Record<string, AttributeValue> — a deeply nested structure of typed wrappers like { S: "hello" } and { N: "42" } instead of plain values. TypeScript's strict typing exposes every awkward edge of this format. The solutions are to use the @aws-sdk/lib-dynamodb document client (which handles marshalling automatically), use @aws-sdk/util-dynamodb to unmarshall manually, or define custom type guards.

The Problem: Raw DynamoDB Types

typescript
1import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
2
3const client = new DynamoDBClient({ region: "us-east-1" });
4
5const result = await client.send(new GetItemCommand({
6  TableName: "Users",
7  Key: {
8    userId: { S: "user-123" }  // Must wrap in { S: ... }
9  }
10}));
11
12// result.Item type is Record<string, AttributeValue> | undefined
13const item = result.Item;
14
15// Accessing values is painful:
16const name = item?.name?.S;          // string | undefined
17const age = item?.age?.N;            // string | undefined (numbers are strings!)
18const tags = item?.tags?.SS;         // string[] | undefined
19const isActive = item?.isActive?.BOOL; // boolean | undefined
20
21// Nested objects are even worse:
22const city = item?.address?.M?.city?.S;  // deep nesting for every field

Every field requires accessing a type-specific property (S for string, N for number, M for map), and numbers come back as strings that need parsing.

typescript
1import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
3
4const ddbClient = new DynamoDBClient({ region: "us-east-1" });
5const docClient = DynamoDBDocumentClient.from(ddbClient);
6
7// Define your item type
8interface User {
9  userId: string;
10  name: string;
11  age: number;
12  email: string;
13  isActive: boolean;
14  tags: string[];
15  address: {
16    city: string;
17    state: string;
18  };
19}
20
21const result = await docClient.send(new GetCommand({
22  TableName: "Users",
23  Key: {
24    userId: "user-123"   // Plain value — no { S: ... } wrapper
25  }
26}));
27
28// Cast the result to your type
29const user = result.Item as User | undefined;
30
31// Now access fields directly
32console.log(user?.name);          // string
33console.log(user?.age);           // number (not string)
34console.log(user?.address.city);  // string — no .M.city.S

The document client from @aws-sdk/lib-dynamodb automatically marshalls and unmarshalls DynamoDB's AttributeValue format. Keys and values use plain JavaScript types.

Solution 2: Manual Unmarshalling

typescript
1import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
2import { unmarshall } from "@aws-sdk/util-dynamodb";
3
4const client = new DynamoDBClient({ region: "us-east-1" });
5
6const result = await client.send(new GetItemCommand({
7  TableName: "Users",
8  Key: {
9    userId: { S: "user-123" }
10  }
11}));
12
13if (result.Item) {
14  const user = unmarshall(result.Item) as User;
15  console.log(user.name);    // plain string
16  console.log(user.age);     // plain number
17}

unmarshall() converts { S: "hello" } to "hello", { N: "42" } to 42, etc. Use this when you need the raw client for specific features but want clean types for the result.

Solution 3: Type-Safe Wrapper Function

typescript
1import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
2
3async function getItem<T>(
4  docClient: DynamoDBDocumentClient,
5  tableName: string,
6  key: Record<string, string | number>
7): Promise<T | null> {
8  const result = await docClient.send(new GetCommand({
9    TableName: tableName,
10    Key: key
11  }));
12  return (result.Item as T) ?? null;
13}
14
15// Usage — fully typed
16const user = await getItem<User>(docClient, "Users", { userId: "user-123" });
17if (user) {
18  console.log(user.name);   // TypeScript knows this is string
19  console.log(user.age);    // TypeScript knows this is number
20}

Handling Number Precision

typescript
1import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
2import { NumberValue } from "@aws-sdk/lib-dynamodb";
3
4// Configure document client to handle big numbers
5const docClient = DynamoDBDocumentClient.from(ddbClient, {
6  marshallOptions: {
7    removeUndefinedValues: true,
8    convertClassInstanceToMap: true,
9  },
10  unmarshallOptions: {
11    wrapNumbers: false,  // true returns NumberValue objects instead of JS numbers
12  },
13});
14
15// With wrapNumbers: true, large numbers are preserved as strings
16// BigInt or decimal.js can then parse them accurately

DynamoDB stores numbers with up to 38 digits of precision. JavaScript's Number type loses precision beyond 2^53. Use wrapNumbers: true if your data contains large integers or high-precision decimals.

Handling Missing Items

typescript
1const result = await docClient.send(new GetCommand({
2  TableName: "Users",
3  Key: { userId: "nonexistent" }
4}));
5
6// result.Item is undefined when the key does not exist — no error thrown
7if (!result.Item) {
8  throw new Error("User not found");
9}
10
11const user = result.Item as User;

GetItem does not throw an error for missing keys. It returns undefined for Item. Always check before casting.

Batch Get with Types

typescript
1import { BatchGetCommand } from "@aws-sdk/lib-dynamodb";
2
3interface Order {
4  orderId: string;
5  userId: string;
6  total: number;
7  status: string;
8}
9
10const result = await docClient.send(new BatchGetCommand({
11  RequestItems: {
12    Orders: {
13      Keys: [
14        { orderId: "order-1" },
15        { orderId: "order-2" },
16        { orderId: "order-3" },
17      ]
18    }
19  }
20}));
21
22const orders = (result.Responses?.Orders ?? []) as Order[];
23orders.forEach(o => console.log(o.orderId, o.total));

Common Pitfalls

  • Using the raw DynamoDBClient instead of DynamoDBDocumentClient: The raw client requires manually wrapping every value in { S: ... }, { N: ... }, etc., and returns items in the same format. The document client handles this automatically and should be the default choice.
  • Forgetting that DynamoDB numbers are strings in the raw SDK: { N: "42" } contains a string, not a number. If you use the raw client, you must call parseInt() or parseFloat() on every numeric field. The document client converts these automatically.
  • Casting result.Item as T without null checking: GetItem returns undefined for Item when the key does not exist. Casting without checking leads to runtime errors. Always check if (result.Item) first.
  • Losing precision with large numbers: JavaScript's Number.MAX_SAFE_INTEGER is 2^53 - 1. DynamoDB supports 38-digit numbers. Use wrapNumbers: true in the document client to preserve precision for large values.
  • Mixing v2 and v3 SDK imports: The v2 SDK uses AWS.DynamoDB.DocumentClient, while v3 uses @aws-sdk/lib-dynamodb. Mixing imports from both SDKs causes confusing type errors. Stick to one SDK version.

Summary

  • Use DynamoDBDocumentClient from @aws-sdk/lib-dynamodb to avoid the { S: ... } / { N: ... } wrapper hell
  • Cast result.Item as YourType for TypeScript type safety after checking for undefined
  • Use unmarshall() from @aws-sdk/util-dynamodb when you need the raw client but want plain types
  • Create a generic getItem<T>() wrapper function for reusable, type-safe access
  • Handle missing items explicitly — GetItem returns undefined, not an error
  • Enable wrapNumbers: true for high-precision numeric data

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.