Typiaで配信JSONの安全性を構造的に担保する

Typiaで配信JSONの安全性を構造的に担保する

こんにちは。RightTouch でエンジニアをしている追木です。

今回は、RightTouch で開発しているプロダクトにおいて、エンドユーザーに配信される設定 JSON に不要な値が混ざるのを防止するために部分的に Typia を導入した話を紹介します。

JSON に「余計なもの」が混ざる問題

Web アプリケーション開発において、サーバーサイドで生成した JSON をエンドユーザーのブラウザに配信するケースは多いと思います。

私たちのプロダクトでも、管理画面で設定した内容をもとに設定用の JSON をビルドし、エンドユーザーの Web サイト上で動作するクライアントサイドスクリプトに配信しています。

ここで問題になるのが、本来エンドユーザーに見える必要のない値が JSON に含まれてしまうリスクです。

たとえば、サーバー内部でしか使わない管理用のフラグ、廃止済みだが DB にまだ残っているプロパティ、あるいは将来の拡張のために追加したがまだクライアントでは使っていないフィールドなど、こうした「余計なプロパティ」が配信 JSON に紛れ込むと:

  • 不要なデータがネットワーク上に流れる(パフォーマンスへの影響)
  • 内部実装の詳細が漏洩する(セキュリティリスク)
  • 型定義と実際の出力が乖離する(保守性の低下)

といった問題を引き起こします。

従来はコードレビューで「このプロパティ、配信する必要ある?」と人の目で確認したり、手動でpickomitを書いてフィルタリングしていました。しかし、プロパティが増えるたびにフィルタリングコードを更新する必要があり、漏れが発生しやすい構造でした。

「型定義に存在するプロパティだけを通す」という仕組みを、手動ではなく自動で実現できないか?

というのが、今回のモチベーションです。

どの方法で解決するか

余分なプロパティを除去するアプローチはいくつか考えられます。

方法 A:手動で pick / omit する

// 必要なプロパティだけを手動で列挙
const safeOutput = {
  id: config.id,
  name: config.name,
  design: config.design,
  // ... 数十個のプロパティを手動で列挙
};
  • Pros
    • 追加のライブラリが不要
    • 何が含まれるか一目で分かる
  • Cons
    • プロパティが追加されるたびにフィルタリングコードも更新が必要
    • 型定義を変更したのにフィルタを更新し忘れるリスクがある(まさに私たちが直面していた問題)
    • プロパティ数が多いと列挙が膨大になり保守コストが高い

方法 B:JSON スキーマベースのバリデーション(zod, ajv 等)

// zodの場合
const ConfigSchema = z.object({
  id: z.string(),
  name: z.string(),
  design: z.object({ ... }),
});

// .strip() で余分なプロパティを除去
const safeOutput = ConfigSchema.strip().parse(config);
  • Pros
    • スキーマ定義に基づいた自動除去が可能
    • エコシステムが充実しており情報が豊富
  • Cons
    • TypeScript の型定義とは別にスキーマ定義を書く必要がある(二重管理)
    • 型とスキーマの乖離リスクが残る
    • 既存の型定義が多い場合、スキーマへの書き直しコストが大きい

方法 C:Typia の assertPrune

import typia from 'typia';

// TypeScript の型定義がそのままバリデーションルールになる
const safeOutput = typia.misc.assertPrune<DeliveryConfig>(config);
  • Pros
    • TypeScript の型定義そのものが Single Source of Truth になる(スキーマの二重管理が不要)
    • 型定義を変更すれば自動的にバリデーションルールも更新される
    • AOT コンパイルによる高いランタイムパフォーマンス
  • Cons
    • Typia 自体の認知度がまだ高くなく、チーム内での学習コストがある
    • AOT コンパイルのためのセットアップ(コード生成の仕組み)が必要

どれにする?

元々型ファーストなValidatorを使って近いことはできていました。なので、極力型ファーストな方法の解決を目指したいと思っていました。

また、既存の方法の課題として、再帰的な型や複雑にネストされた型ではうまく動作しないこともあり、都度簡易な型に差し替える必要がありました。これが割としんどく、部分的な二重管理にもつながるので、主に解決したいポイントです。

つまり、選定の判断基準としてはこんな感じです。

  • 型ファーストな方法で解決できるか
  • 再帰的な型や複雑にネストされた型でも特に工夫なくうまく動作するか

この観点で検証したところ、Typia はどちらも問題なく満たすことがわかったため、今回は方法Cを採用することにしました。

Typia とは?

Typiaは、TypeScript の型情報をコンパイル時に解析し、ランタイムバリデーションコードを自動生成するライブラリです。

通常のバリデーションライブラリ(zod, io-ts 等)が独自のスキーマ定義言語を必要とするのに対し、Typia はTypeScript の型定義をそのまま使うのが最大の特徴です。

また、AOT(Ahead-of-Time)コンパイルにより、ランタイムのパフォーマンスも非常に優れています。

主要な関数群

Typia にはユースケースに応じた複数の関数が用意されています。ここでは主要なものを紹介します。

typia.is<T>() — 型チェック(boolean)

interface IMember { name: string; age: number; }
const input: unknown = { name: "Alice", age: 30, secret: "internal" };

const result: boolean = typia.is<IMember>(input);
// true / false を返すだけ。例外は投げない。

型に合致するかどうかをbooleanで返します。シンプルで高速ですが、どこが間違っているかの情報は得られません。

typia.assert<T>() — 型チェック(例外スロー)

try {
  const validated: IMember = typia.assert<IMember>(input);
} catch (e) {
  // TypeGuardError: path, expected, value の情報を含む
}

型に合わない場合はTypeGuardErrorをスローします。最初のエラーを検出した時点で即座に停止するため、「そもそもデータがおかしければ処理を続けたくない」ケースに適しています。

typia.validate<T>() — 型チェック(全エラー収集)

const result = typia.validate<IMember>(input);
if (!result.success) {
  console.log(result.errors);
  // [{ path: "$input.age", expected: "number", value: "twenty" }, ...]
}

例外を投げず、すべてのエラーを配列で返します。 ユーザー入力のフォームバリデーションなど、「すべての不備をまとめて表示したい」場合に便利です。

typia.misc.prune<T>() — 余分なプロパティの除去(バリデーションなし)

typia.misc.prune<IMember>(input);
// → { name: "Alice", age: 30 }   ← "secret" が除去される

型定義に存在しないプロパティを除去します。バリデーションは行わないので、型に合わない値があってもエラーにはなりません。入力オブジェクトを直接変更する点にも注意が必要です。

typia.misc.assertPrune<T>() — バリデーション+余分なプロパティの除去(今回採用)

const safeOutput = typia.misc.assertPrune<DeliveryConfig>(input);
// 1. 型に合わない値があれば TypeGuardError をスロー
// 2. 型定義に存在しないプロパティを除去
// 3. バリデーション済み&クリーンなオブジェクトを返却

assertprune の組み合わせです。「型に合わない値はエラーにしたいが、余分なプロパティは安全に除去したい」という今回のユースケースにぴったりでした。

typia.assertEquals<T>() — 厳密モード(余分なプロパティもエラー)

// 余分なプロパティがあること自体をエラーとする
const validated = typia.assertEquals<IMember>(input);
// → TypeGuardError: "secret" is not allowed

今回は「余分なプロパティはエラーにせず静かに除去したい」ため、こちらではなくassertPruneを選択しました。assertEqualsテストコードなど厳密性が求められる場面に向いています。

なぜ assertPrune なのか

要件 assert prune assertPrune assertEquals
型に合わない値をエラーにできる ×
余分なプロパティを除去できる × ×(エラーにする)
不用意に処理が止まらない △(既存データで落ちうる)

assertEqualsを使うと、既存のデータに余分なプロパティが含まれている時点でエラーになり、ビルドが止まってしまいます。一方、pruneだけだと型に合わない不正な値を見逃してしまいます。assertPrune不正な値はエラーにしつつ、余分なプロパティは安全に除去するという、本番環境の既存データに対して最も実用的なバランスを提供してくれます。

既存データへの影響確認

方法が決まったとしても、もちろんいきなり本番に投入するわけにはいきません。

assertPruneは型定義に存在しないプロパティを除去しますが、もし除去されるプロパティの中にクライアントサイドで実は使われているものがあれば、配信後に機能が壊れてしまいます。型定義漏れの可能性もゼロではないため、「何が取り除かれるか」を事前に把握し、取り除いて問題ないかを確認するプロセスが不可欠です。

DryRun の流れ

既存の全データ
    ↓
JSON生成(従来通り)
    ↓
assertPrune適用
    ↓
before/after を比較
    ↓
pruneされたプロパティを一覧化
    ↓
各プロパティが消えることによる影響がないことを確認

prune 前後のオブジェクトを比較し、差分があった場合は削除されたプロパティのパスを再帰的に検出するロジックを用意しました。この DryRun によって「Typia を入れたらビルドが壊れた」「クライアントの挙動が変わった」といった事故を未然に防ぎ、安心して本番導入に進むことができました。

差分の正規化

prune 前後のオブジェクトを再帰的に比較し、削除されたプロパティのパスを検出しています。

配列要素のインデックス([0], [1], [2]...)を [*] に置換してから重複を除去することで、「どのプロパティが除去されたか」をデータ件数に関わらず簡潔に把握できるようにしました。

// pruneで除去されたプロパティのパス(正規化前)
// items[0].legacyFlag
// items[1].legacyFlag
// items[2].legacyFlag
// ...(データ件数分だけ出力される)

// 正規化後
// items[*].legacyFlag

ここで上がった項目に関して、網羅的にAIにロジックを確認させ、どれも現状のロジックでは使われていないことを確認した上で本番に適用しました。

こういった網羅的な調査においても AI は非常に便利です。ちなみに自分はClaude Code派です。

ビルドパイプラインへの組み込み

私たちのシステムでは、DB から取得した各種データをもとに JSON をビルドする関数があります。Typia による prune は、この関数の戻り値に対して適用しています。

// 疑似コード
export const buildConfig = async ({ projectId }) => {
  // 1. DBから各種データを取得・組み立て
  const configOrig = {
    id: project.id,
    theme: project.theme,
    widgets: await buildWidgets(projectId),
    features: await getFeatureFlags(projectId),
    // ... 多数のプロパティ
  };

  // 2. DBドキュメント → プレーンオブジェクトに変換
  const config = JSON.parse(JSON.stringify(configOrig));

  // 3. Typiaでprune(型定義にないプロパティを除去)
  pruneConfig(config);

  // 4. 差分をログ出力
  logValidationDiff('config', projectId, configOrig, config);

  return config;
};

ポイントはステップ 2 のJSON.parse(JSON.stringify(...))です。ORM や ODM が返すドキュメントオブジェクトは独自のプロトタイプや getter を持っていることがあり、Typia の prune でプロパティをdeleteした際に予期しない挙動を起こす場合があります。事前にプレーンオブジェクトに変換することで、この問題を回避しています。

まとめ

今回は、Typia を用いて、エンドユーザーに配信される JSON から型定義に存在しない余分なプロパティを自動的に除去する仕組みを作った話について書いてみました。

エンドユーザーに届くデータの安全性は、コードレビューや手動フィルタリングといった人の注意力に依存する仕組みではなく、構造的に担保されるべきだと考えています。型定義に含めたものだけが配信され、含めなければ除去されるという仕組みを入れたことで、安心して開発を進められるようになったのでよかったかなと思っています。

採用情報

RightTouch では、Product Engineerをはじめ、一緒に最高のプロダクトを作り、ユーザーに届ける仲間を積極採用中です!

カジュアル面談も歓迎しています。ご興味があれば、ぜひ採用ページをご覧ください。