エラーが出ないバグに遭遇した:DynamoDB GSI射影 × 型アサート
はじめに
人材育成室育成メンバーの石黒です。
先日、業務でこのバグに遭遇し、原因を調査した際に最小構成で再現してみました。
概要
DynamoDBのGSI(グローバルセカンダリインデックス)と
TypeScriptの型アサートの組み合わせによって、エラーが一切発生しないまま
意図した値が反映されないバグ が発生していました。
何が起きたか
概要は以下です。
- DynamoDBのGSIを、必要な属性を一部だけ射影する設定で作成する
- GSI経由でクエリした結果を、 フルカラムを持つ型へ
asで型アサートする - 元々「値が無い場合はデフォルト値を使う」というフォールバック処理が存在した
- 結果、GSI経由で取得したデータは常にその属性がundefinedになるため、
DBに実際の値を入れていても、常にデフォルト値が採用されてしまう
型の上では問題なく、かつ undefined が許容されているため、エラーは発生しません。
ただ、データが投入されていても参照されず、デフォルト値が適用されてしまっていました。
GSIのProjection設定も、型アサートもそれぞれ単体では問題のない仕組みですが、両者が組み合わさることで見えづらい形の不具合を引き起こしていました。
再現してみた
CDKで最小構成の環境を作り、再現してみます。
テーブルとGSIの定義
const table = new Table(this, 'Orders', {
partitionKey: { name: 'id', type: AttributeType.STRING },
});
table.addGlobalSecondaryIndex({
indexName: 'status-index',
partitionKey: { name: 'status', type: AttributeType.STRING },
sortKey: { name: 'updatedAt', type: AttributeType.STRING },
projectionType: ProjectionType.INCLUDE,
nonKeyAttributes: ['someOtherField'], // amountを含めない
});
ここではamountという属性をGSIの射影対象から意図的に外しています。
const result = await client.send(new QueryCommand({
TableName: TABLE_NAME,
IndexName: 'status-index',
KeyConditionExpression: '#status = :status',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: { ':status': { S: status } },
}));
// GSIレスポンス(amount欠落)を、フルカラム型へ無検証で型アサート
const items = (result.Items ?? []).map(item => unmarshall(item) as OrderItem);
OrderItem 型は amount?: number を含むフルカラムの型です。
as によって、コンパイラは「amountを持っているかもしれないデータだ」と信じてしまいますが、
実際にはGSI射影に含まれていないため、amountは返ってきません。
フォールバック処理
function resolveAmount(order: OrderItem): number {
return order.amount ?? DEFAULT_AMOUNT;
}
これは元々「amountが未設定のレコードにはデフォルト値を使う」という仕様で実装されていたものです。
しかし、GSI経由で取得したデータは構造的にundefinedになるため、この関数は常にデフォルト値を返してしまいます。
実際に動かした結果
DynamoDBにamount=5000のレコードを保存した状態で、それぞれ取得してみます。
テーブル本体から直接取得(GetItem)
{"item": {"amount": 5000, "id": "order-1", ...}}
正しくamountが見えます。
GSI経由で取得(Query + IndexName指定)
{"items": [{"id": "order-1", "status": "PENDING", ...}]}
amountキー自体が存在しません。
フォールバック処理を実行した結果
{"results": [{"id": "order-1", "effectiveAmount": 1000}]}
5000を入れたはずなのに、デフォルト値の1000が採用されています。
CloudWatch Logsにも、以下のように出力されていました。
id=order-1, order.amount=undefined, effectiveAmount=1000
StatusCodeは200で、エラーは起きていません。
なぜ検知できないのか
原因は as による型アサートです。
asは「このデータはこの型として扱ってよい」と開発者がコンパイラに宣言する構文であり、実際のデータ構造との整合性は一切保証されません。
今回のケースでは、GSIから取得したデータには元々amountという
フィールドが存在しません。しかしas OrderItemと書いた瞬間、
コンパイラは「amountはあるかもしれないし、無いかもしれない」
(amount?: number)という前提でコードを検査するようになります。
amount?: numberというOptional型の設計自体は正しいです。
問題は、そもそもamountを持たないGSI経由のデータに対して、この型をasで無理やり当てはめてしまったことにあります。
では、GSI経由のデータを安全に扱うには、どうすればよかったのでしょうか。
対策
GSI経由で取得できるデータ専用の型を分離して型アサートすることです。
// GSIから実際に取得できる項目のみの型(amountを含めない)
interface OrderGSIItem {
id: string;
status: string;
updatedAt: string;
}
// GSI専用の型へ変更する
const items = (result.Items ?? []).map(item => unmarshall(item) as OrderGSIItem);
こうしておけば、gsiItem.amountとコードで書いた時点でコンパイルエラーになり早期に誤りを検知できます。
また、asによる型アサートを避け、Zodなどでランタイム検証を行うことも有効です。
GSI経由でのデータ取得には IndexName の指定が必須のため、
既存コードに同様の罠が潜んでいないか確認するには、「IndexNameを指定しているQuery」と「そのレスポンスに対するas」の組み合わせを検索すると、該当箇所を見つけやすくなります。
grep -rn "IndexName" --include="*.ts" src/
まとめ
- GSIのProjectionが不足していると、特定の属性がクエリ結果から構造的に欠落する
- 型アサート(as)は、この欠落をコンパイラに黙って見逃させてしまう
- Optionalなフィールドへのフォールバック処理は、想定していない原因によるundefinedに対しても、区別なく同様の処理を行ってしまう
感想
元々RDBMSを中心に扱っていたこともあり、DynamoDBのセカンダリインデックスの挙動について理解が浅い部分がありました。
再現する中で DynamoDB の解像度が上がったのを実感できました。







