この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 7 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする(この記事)
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく(公開予定)
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する(公開予定)
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する(公開予定)
- 第 16 回 外部APIに依存しないE2Eの組み方(公開予定)
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体(公開予定)
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する(公開予定)
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方(公開予定)
TL;DR
対象読者は、入力値のバリデーションをどこに置くか毎回迷っているエンジニアです。TypeScript の型で「検証済みかどうか」を表すやり方を実例で見ます。
-
stringとSearchKeywordを 別の型にする ことで、検証漏れをコンパイル時に検出できます -
parse(例外を投げる)とtryParse(既定値に倒す)を 用途で使い分ける のが要点です - 検索キーワードから修飾子構文を弾く実装は、インジェクション対策そのもの です
「ただの文字列」問題
こういうコードを書いたことがあると思います。
function search(keyword: string, page: number) { /* ... */ }
呼び出し側でこう間違えても、TypeScript は何も言いません。
search(userInput, 1) // userInput は検証済み? 未検証?
search("", 1) // 空文字は?
search("a".repeat(10000), 1) // 長すぎる文字列は?
string という型は「文字列である」ことしか保証しません。「検索キーワードとして妥当である」ことは保証しません。
ブランド型 — 型に印を付ける
gem-hunter の解き方はこうです。
declare const brand: unique symbol
export type PerPage = (typeof ALLOWED_PER_PAGE)[number] & { readonly [brand]: 'PerPage' }
unique symbol を使って、実行時には存在しないが型の上では区別される印 を付けます。これで 20(ただの数値)と PerPage(検証を通った表示件数)が別の型になります。
const n: number = 20
const p: PerPage = 20 // ❌ 型エラー。number は PerPage ではない
PerPage の値を作る方法は 1 つだけになります。検証関数を通ることです。
この節が難しく感じたら、いまは飛ばして構いません。 「検証を通った値だけが持てる型がある」とだけ分かっていれば、後の回は読めます。
全体像
import { DomainValidationError } from '../errors'
/** 表示件数として許可する値(`AR-3`)。任意値はキャッシュ断片化を招くため 3 択に固定する。 */
export const ALLOWED_PER_PAGE = [20, 50, 100] as const
export const DEFAULT_PER_PAGE = 20
declare const brand: unique symbol
/** 検索結果の表示件数(20 / 50 / 100 のみ)。 */
export type PerPage = (typeof ALLOWED_PER_PAGE)[number] & { readonly [brand]: 'PerPage' }
function isAllowedPerPage(value: number): value is (typeof ALLOWED_PER_PAGE)[number] {
return (ALLOWED_PER_PAGE as readonly number[]).includes(value)
}
export function parse(raw: number): PerPage {
if (!isAllowedPerPage(raw)) {
throw new DomainValidationError(
'PerPage',
raw,
`表示件数は ${ALLOWED_PER_PAGE.join('/')} のいずれかで指定してください`,
)
}
return raw as PerPage
}
/** URL 改変で 500 にしないため、不正値は既定表示件数へ倒す(domain-model.md §4)。 */
export function tryParse(raw: string | number | null | undefined): PerPage {
if (raw == null || raw === '') {
return DEFAULT_PER_PAGE as PerPage
}
const value = typeof raw === 'number' ? raw : Number(raw)
try {
return parse(value)
} catch {
return DEFAULT_PER_PAGE as PerPage
}
}
38 行のファイルですが、読みどころが 4 つあります。
1. 表示件数を 3 択に固定した理由
/** 表示件数として許可する値(`AR-3`)。任意値はキャッシュ断片化を招くため 3 択に固定する。 */
?per_page=37 のような任意の値を許すと、キャッシュのキーが値の数だけ増えます。20/50/100 に固定すれば、キャッシュのバリエーションは 3 通りで済みます。
UI 上の都合ではなく、キャッシュ効率 という理由が値の制約になっています。
2. parse と tryParse の二段構え
同じ検証に対して、振る舞いの違う 2 つの入り口 があります。
| 関数 | 不正値のとき | どこで使うか |
|---|---|---|
parse |
例外を投げる | プログラムの内部から呼ぶとき |
tryParse |
既定値に倒す | URL のクエリなど、ユーザーが自由に書ける値 |
tryParse のコメントが判断理由です。
URL 改変で 500 にしないため、不正値は既定表示件数へ倒す(domain-model.md §4)。
https://example.com/ja?per_page=9999 と手で書き換えた人に 500 エラーを見せるのは、サービスとして望ましくありません。20 件で表示すればいい。
一方で、プログラム内部のバグで不正値が渡ったなら、それは黙って倒さず落とすべきです。 だから 2 つ用意して、境界の性質で使い分けます。
これは実務で応用が効く型です。「バリデーションは例外か戻り値か」という議論は、どちらか一方を選ぶ問題ではなく、境界ごとに選ぶ問題 なのだと分かります。
3. 型ガードで絞り込む
function isAllowedPerPage(value: number): value is (typeof ALLOWED_PER_PAGE)[number] {
value is ... という戻り値の型注釈は 型ガード と呼ばれるもので、「この関数が true を返したら、引数はこの型である」と TypeScript に教えます。
4. ドメイン専用のエラー型
DomainValidationError は「どのモデルの」「どんな値が」「なぜ弾かれたか」を持ちます。汎用の Error を投げると、上位層は文字列を見て判断するしかありません。
検索キーワードの検証はセキュリティ対策
もう 1 つ、search-keyword.ts にはこんなコメントがあります。
/**
* 🔴 検索式の修飾子構文(`名前:値` と否定形 `-名前:値`)。
*
* キーワードは検索クエリ文字列へそのまま載る。修飾子を書けてしまうと、アプリが付ける
* 公開限定条件と同じ種類の条件をユーザー側から重ねられ、検索の意味そのものを
* 差し替えられる(インジェクション)。検索式は「キーワードだけ」を受け取る契約にして、
* 構文を持ち込ませない。
*
* 判定は修飾子名を列挙せずパターンで行う(特定検索エンジンの語彙をドメインへ持ち込まない・
* 未知/新設の修飾子も自動的に塞げる)。
*/
const QUALIFIER_PATTERN = /(^|\s)-?[A-Za-z_]+:/
GitHub の検索 API には is:public user:foo stars:>100 のような 修飾子構文 があります。ユーザーの入力をそのままクエリに載せると、アプリが付けている「公開リポジトリだけ」という条件を ユーザー側から打ち消せてしまいます。SQL インジェクションと同じ構図です。
対策の設計が 2 段構えになっています。
-
禁止する修飾子を列挙しない。
is:user:… と並べる方式だと、GitHub が新しい修飾子を追加したときに漏れます。「英字:という形」をパターンで弾けば、未知の修飾子も自動的に塞がります -
特定サービスの語彙を domain に持ち込まない。
is:publicという具体的な文字列は domain には出てきません(第 9 回で、その具体的な処理が infrastructure 層にあることを見ます)
値をまとめて 1 つの型にする
個々の値オブジェクトができたら、それらをまとめた型を作ります。
/**
* 検索条件。URL と 1 対 1 で対応する(NFR-2)。
*/
export type SearchQuery = {
readonly keyword: SearchKeyword
readonly page: PageNumber
readonly sort: SortOrder
readonly perPage: PerPage
}
export function searchQuery(input: {
keyword: string
page?: number
sort?: string
perPage?: number
}): SearchQuery {
return {
keyword: searchKeyword(input.keyword),
page: input.page === undefined ? (DEFAULT_PAGE as PageNumber) : pageNumber(input.page),
sort: input.sort === undefined ? DEFAULT_SORT_ORDER : parseSortOrder(input.sort),
perPage: input.perPage === undefined ? (DEFAULT_PER_PAGE as PerPage) : parsePerPage(input.perPage),
}
}
入口は生の値(string / number)、出口は検証済みの値 という形です。境界で 1 度変換すれば、その先はすべて検証済みの値だけが流れます。
readonly が全部のフィールドに付いているのは、作った後に書き換えられないようにするためです。
なお src/domain/model/ に置かれているのは「値の検証」だけではありません。locale.ts page-number.ts sort-order.ts repository-full-name.ts date-seed.ts に加えて、あとから gem.ts gem-index.ts gem-keyword.ts gem-shortlist.ts digest-diff.ts が足されています。
とくに gem-keyword.ts が面白いところです。ここには GitHub の検索とは別の照合規則(英数字の単語境界一致・複数語は全語 AND・0 件のときだけ 1 語へ緩める)が、フレームワークにも外部 API にも依存しない純粋関数として置かれています。「検索の仕方」そのものを自分たちの語彙で決めてよい、という例です。
エラーの種類を数えておく
domain にはエラーの分類も置かれています。
export type ErrorKind =
/** fetch 自体が失敗(到達不可) */
| 'network'
/** 一次レート制限(枠の枯渇。`x-ratelimit-reset` で復帰時刻が分かる) */
| 'rateLimitPrimary'
/** 二次レート制限(短時間の集中。`retry-after` 秒後に再試行できる) */
| 'rateLimitSecondary'
/** 401 / 403(レート制限以外)。サーバー設定の問題なので汎用エラーとして扱う */
| 'auth'
/** 入力・検索クエリが不正(422 / 値オブジェクトの検証失敗) */
| 'validation'
/** 対象なし */
| 'notFound'
/** 5xx・スキーマ不一致・その他の上流異常 */
| 'upstream'
7 種類それぞれに、利用者に何を伝えられるか まで書いてあります。「一次レート制限は復帰時刻が分かる」「二次レート制限は何秒後に再試行できるかが分かる」。この違いがそのまま画面の文言の違いになります(第 11 回)。
ここで大事なのは、HTTP のステータスコードをそのまま持ち込んでいない ことです。401 ではなく auth、404 ではなく notFound。domain は HTTP を知らなくていい、という線引きです。
まとめ
- ブランド型で「検証済みの値」と「ただの文字列」を型として区別する
-
parse(例外)とtryParse(既定値に倒す)を 境界の性質で使い分ける - 制約の理由をコメントに残す(表示件数 3 択の理由は キャッシュ効率)
- 検索キーワードの検証は インジェクション対策。禁止語の列挙ではなくパターンで塞ぐ
- エラーは HTTP ステータスではなく ドメインの語彙 で分類する
-
値オブジェクトは「どこでも使うほど良いもの」ではない。このリポジトリには
PageNumber(上限 50)を あえて使わない 経路が 1 つあり、gem-index-port.tsの JSDoc に「規約違反ではなく意図的な非採用である」と根拠が残されている(理由は第 11 回で扱います)
シリーズの前後の記事
- ⬅️ 前の記事: 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- ➡️ 次の記事: 第 8 回 DIコンテナを使わない依存性逆転
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。