Accurately Typing DynamoDB Data from the AWS SDK using TypeScript

Accurately Typing DynamoDB Data from the AWS SDK using TypeScript

Learn how to accurately and easily type data retrieved from a DynamoDB table via the AWS SDK using TypeScript and how to avoid using any types!

This post was originally published on my blog on 10th August 2023

If you’ve ever tried building a project with DynamoDB and used the AWS SDK with TypeScript to read and write data to the database, you’ll probably be familiar with the issues of the SDK not typing the data you retrieve from the database.

This issue makes sense because the SDK has no way of knowing what attributes you have on every item in your database before you’ve retrieved them but this doesn’t make the issue any less annoying from a developer's perspective.

But, don’t worry in this tutorial, I’ll give you a solution so you can keep the power of the SDK and have accurate types from TypeScript for you to use in your application so let’s get into it.

The Issue

Before we jump into the solution let’s take a quick look at the problem, below is some example code using the QueryCommand from the AWS SDK to retrieve all items in the database that match the given pk and start with the same sk value.

const { Items } = await dbClient.send(
  new QueryCommand({
    TableName: 'YOUR_TABLE_NAME',
    ExpressionAttributeValues: {
      ':p': pk,
      ':s': sk,
    },
    KeyConditionExpression: 'pk = :p and begins_with(sk, :s)',
  })
);

The issue with this code is the TypeScript type given to Items will be Record<string, any>[] | undefined which is far from helpful. If we wanted to access the properties on the items returned from our database, TypeScript would throw an error as it has no idea what is present on the data and what isn’t so let’s fix that so we can access the actual data we retrieve with the correct types.

The Solution

To resolve the issue with the types not being applied to the data being returned, we first need to do some digging into the SDK package itself. If we dig into the QueryCommand we used above, we can actually see the return type being used is the one below.

export declare type QueryCommandOutput = Omit<__QueryCommandOutput, "Items" | "LastEvaluatedKey"> & {
    Items?: Record<string, NativeAttributeValue>[];
    LastEvaluatedKey?: Record<string, NativeAttributeValue>;
};

This is where we can see the not-very helpful Record<string, NativeAttributeValue>[] | undefined is being returned to us. But, what if we could override that with our own types?

What we’re going to do is take the QueryCommandOutput type and remove the built-in type for Items and instead give it our own type that matches the data we’re retrieving from the database. We can do this with the below code.

import { QueryCommandOutput } from '@aws-sdk/lib-dynamodb';

type Item = {
    prop1: string;
}

const { Items } = (await dbClient.send(
  new QueryCommand({
    TableName: 'YOUR_TABLE_NAME',
    ExpressionAttributeValues: {
      ':p': pk,
      ':s': sk,
    },
    KeyConditionExpression: 'pk = :p and begins_with(sk, :s)',
  })
)) as Omit<QueryCommandOutput, 'Items'> & {
    Items?: Item[];
  };

As mentioned above, we take the QueryCommandOutput type and use it with the Omit feature from TypeScript to remove the Items type. We then extend that type with our own type for Items which will be used for the Items property returned from the SDK.

So, in the case of our example above we took our Items property from having a type of Record<string, NativeAttributeValue>[] | undefined to having a type of Item[] | undefined which is a lot nicer to use and work with!

Making It Reusable With Generics

But, let’s not stop there, we can take this one step further and make our solution easier to reuse across our entire project by pairing it with generics and creating a new standalone type like the one below.

export type IQueryCommandOutput<T> = Omit<QueryCommandOutput, 'Items'> & {
  Items?: T;
};

We can now use this type with any QueryCommand in our codebase and provide it with a type to substitute with the generic T so we can have nicely typed data for all of our queries. Here is our example updated to use this new abstraction.

// types.ts

import { QueryCommandOutput } from '@aws-sdk/lib-dynamodb';

export type Item = {
    prop1: string;
}

export type IQueryCommandOutput<T> = Omit<QueryCommandOutput, 'Items'> & {
  Items?: T;
};
// query.ts

import { Item, IQueryCommandOutput } from './types';

const { Items } = (await dbClient.send(
  new QueryCommand({
    TableName: 'YOUR_TABLE_NAME',
    ExpressionAttributeValues: {
      ':p': pk,
      ':s': sk,
    },
    KeyConditionExpression: 'pk = :p and begins_with(sk, :s)',
  })
)) as IQueryCommandOutput<Item[]>;

Example Code Snippets

We’ve covered the QueryCommand from the AWS SDK throughout this post but here are the snippets you’ll need for the other common commands used when interacting with a DynamoDB table via the AWS SDK.

Scan

export type IScanCommandOutput<T> = Omit<ScanCommandOutput, 'Items'> & {
  Items?: T;
};

Query

export type IQueryCommandOutput<T> = Omit<QueryCommandOutput, 'Items'> & {
  Items?: T;
};

Get

export type IGetCommandOutput<T> = Omit<GetCommandOutput, 'Item'> & {
  Item?: T;
};

Put

export type IPutCommandOutput<T> = Omit<
  PutCommandOutput,
  'Attributes'
> & {
  Attributes?: T;
};

Delete

export type IDeleteCommandOutput<T> = Omit<
  DeleteCommandOutput,
  'Attributes'
> & {
  Attributes?: T;
};

Update

export type IUpdateCommandOutput<T> = Omit<
  UpdateCommandOutput,
  'Attributes'
> & {
  Attributes?: T;
};

Considerations

While this method does work great and all of the values returned to you from the SDK will have the appropriate types you passed to the generic, something you will need to bear in mind is that you’ll need to manually maintain the TypeScript types/interfaces you use.

This is because the types can’t be created from the database items themselves so we need to manually create the types and align them with the data in the database. This of course could introduce some issues if the types aren’t kept up to date or aren’t accurate to the data being stored.

However, I do think this issue is a minor one compared to the benefits of having working types assigned to the data returned from the SDK.

Closing Thoughts

We’ve now reached the end of this post; in this tutorial, we’ve looked at how to type the data returned to us from a DynamoDB table using the AWS SDK in a TypeScript project. I hope you found this post helpful until next time.

Thank you for reading

Coner