⛑️

[TypeScript]Dynamodbのクエリ結果を型安全に取り扱う方法

2023/12/19に公開

動機

TypeScriptでDynamodbDocumentClientを使ってdynamodbにクエリ実行するコードを書いていました。
公式ドキュメントに沿って実装していると、sendの返り値の型がQueryCommandOutputという型で帰ってきます。

const cmd = new QueryCommand({
  TableName: this.tableName,
  KeyConditionExpression: `groupId = :groupId)`,
  ExpressionAttributeValues: {
    ':groupId': groupId,
  },
});

const res = await this.ddbDocClient.send(cmd);
// const res: QueryCommandOutput

実際のテーブルアイテムはItemsというプロパティから取得できますが、型がRecord<string, any>[]となっており、型安全ではありません。

当然anyのまま扱うと型チェックが入らないため、undefinedを踏んでしまう危険性があります。

const len = res.Items[0].myAttr.length // Cannot read properties of undefined (reading 'length')

今回はこのクエリ結果を型安全に扱う方法を調べたのでご紹介します。

DynamoDBのクエリ結果を型安全に扱う

色々やり方はあると思いますが、ここではclass-transformer/class-validatorを使ってスキーマを定義・検証しようと思います。

まずはアプリケーションが期待するDynamodbのスキーマをclassで定義します。
各Attributeにはclass-validatorのデコレータを付与してバリデーションの条件を設定します。

user.schema.ts
import { IsEnum, IsString, IsISO8601 } from "class-validator";

export enum UserType {
  User = 'User',
  Admin = 'Admin',
}

export class User {
  @IsString()
  groupId: string;

  @IsEnum(UserType)
  userType: UserType

  @IsISO8601({ strict: true })
  createdAt: string;
}

次にpureなObjectであるクエリ結果をclass-transformerでクラスに変換し、validateを実行します。
以下の例ではvalidateに通らなかったItemは除外して、validateに通過したItemだけをUserクラスとして返却しています。
なお、class-validatorのvalidateは非同期関数なので、非同期を扱えるasyncFilter関数のようなものを定義しておくと便利です。

helper.ts
export async function asyncFilter<T = any>(
  array: T[],
  asyncCallback: (item: T) => Promise<boolean>,
): Promise<T[]> {
  const bits = await Promise.all(array.map(asyncCallback));
  return array.filter((_, i) => bits[i]);
}
user.repository.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  QueryCommand,
  QueryCommandOutput,
} from '@aws-sdk/lib-dynamodb';
import { plainToClass } from 'class-transformer';
import { asyncFilter } from './helper';
import { User } from './user.schema'

export const getUsers = async(groupId: string): Promise<User[]> {
  const ddbDocClient = DynamoDBDocumentClient.from(new DynamoDBClient())
  const cmd = new QueryCommand({
    TableName: this.tableName,
    KeyConditionExpression: `groupId = :groupId)`,
    ExpressionAttributeValues: {
      ':groupId': groupId,
    }
  });
  
  const res = await this.ddbDocClient.send(cmd);

  // class-transformerでクラスインスタンスに変換する
  const users = res.Items.map((item) => {
    return plainToClass(User, {
      groupId: item.groupId,
      userType: UserType[item.userType],
      createdAt: item.createdAt,
    }
  })
  
  // validateに通ったItemだけをUserとして返却する
  const validatedUsers = await asyncFilter(users, async (user) => {
    const validateResult = await validate(user);
    if (validateResult.length > 0) {
      console.error(`Invalid item. ${user}`)
      return false;
    }
    return true;
  });
  
  return validatedUsers
}

以上の例でDynamodbのクエリ結果を型安全に取り扱うことができます。
このやり方であればデコレータで定義できるので、TypeORMのようにtype-graphqlのスキーマクラスと共用したりすることもできて便利かと思います。

その他の選択肢

真面目には試していないのですが、DynamodbのORMライブラリが多数存在しており、用途が合えば使えるかもしれません。
(個人的にはどれもライブラリが推奨するスキーマ定義のお作法に従う必要があり、既存のスキーマとすり合わせが難しいため採用を見送りました。)

Discussion