Architecture2026.08.16 · 6 min read

kind を増やしても表せない — discriminatedUnion の軸と、読者が知りたい軸は直交していた

NCP認証基盤・クラウドインフラ

イベント情報を集める個人サイトの収集パイプラインで、承認カードに「グッズ発売」と出た。届いていたのは7日間だけ売る食べ物だった。分類の kind を1つ増やして直そうとしてやめ、横断的なタグにした。そして同じ思い込みから、投稿に書いてある終了日が落ちていた。

判定は仕様どおりで、違和感は表示ラベル1行から出ていた

Slack に出る承認カードを見て「これ goods 判定されてるけど期間限定の食べ物ってどちらかというとイベントじゃないかな」と思った。抽出をやり直すつもりでプロンプトを開いたら、そこにはこう書いてあった。

「店頭や通販で買える商品(菓子・食品・グッズ・雑貨・書籍)の発売告知は isGoods を true にする」。判定は仕様どおりだった。データモデルの文書でも goods はクレーンゲームの景品まで含む物販の器と決めてあり、食品の先例もすでに公開済みだった。

違和感の出どころは1行だけだった。カードを組み立てる lib/blocks.mjs goods: "グッズ発売" とあり、同じファイルのスキップ理由の文面は「商品の発売告知」と正しい語を使っている。内部の kind 名は物販の器なのに、人の目に触れる唯一の場所だけがグッズと名乗っていた。食品が流れてくるたびに、正しい判定を疑わせていたことになる。

food という kind を足しても、食べ物は拾えない

ラベルを直せば違和感は消える。ただし運営者として本当に欲しかったのは「食べ物だけ見たい」で、それは表示の問題ではない。最初に考えたのは food という kind を足す案だった。

これは成立しない。kind は zod の discriminatedUnion の判別子で、popup / cafe / collab は会場が必須、goods は取扱チャネルが必須、gacha はメーカーが必須、という具合に「どのフィールドが必須か」を決める軸になっている。読者の関心(食べたい / 買いたい / 行きたい)とは別の軸で、公開済み21件を並べると実際に跨っていた。

  • THE GUEST cafe&diner 渋谷・心斎橋 — cafe
  • ちいかわベーカリー KYOTO POP UP SHOP — popup
  • ちいかわ × くら寿司 — popup
  • うま辛カレースナック / くりまんじゅう(カレー) — goods
  • ソフビフィギュア / ブックマーカー — goods(こちらは食べ物ではない)

food を足しても、カフェとベーカリーの popup は拾えない。付け替えれば今度は会場必須が外れて、都道府県軸から落ちる。discriminatedUnion は1エントリに kind を1つしか持たせないので、軸が直交している以上どちらか一方しか表せない。

分類の軸が1本しかないと、新しい絞り込みの要求は必ず「その1本を増やす」形で届く。増やして表せるかどうかは、既存データがその軸をすでに跨いでいるかを見れば分かる。跨いでいたら、それは別のフィールドの仕事だ。

タグを自由文字列にすると、同じ商品が2つに割れる

そこで kind 横断の tags を足したのだが、値を自由文字列にする案は捨てた。このリポジトリには、LLM の作るローマ字スラッグを id に直接使って、同じ商品名が実行ごとに mascot にも masukotto にもなった記録が残っている。

同じことがこの作業中にも起きた。商品表には kurimanju-curry が登録済みなのに、同じ商品名から返ってきたスラッグは kurimanjuu-curry だった。タグを自由文字列にすれば food / foods / sweets で同じ分裂が起きる。

schema/entry.mjs(抜粋)
// ユースケース軸のタグ。**kind とは直交する軸**なので、kind を増やしても表せない。
// **閉じた列挙にする。** 自由文字列にすると LLM が food / foods / sweets を
// 実行ごとに作り、venues / products で踏んだ表記ゆれの罠をここでも踏む。
export const TAGS = ["food", "prize"];

閉じた列挙にできたのは、この軸の語彙が数個で足りるからだ。同じ「絞り込みたい」でも「ちいかわベーカリー関連だけ見たい」というブランド軸は性質が逆で、無限に増えるうえ表記が揺れる。列挙に混ぜれば維持できなくなるので、ここには入れなかった。そちらは巡回企画を束ねる既存の series がすでに担っていて、新しい仕組みは要らなかった。

エラーも出ず validate も通って、7日で終わる商品が終わりの無い商品になった

タグを足す作業の途中で、同じエントリのもっと悪い欠落に気づいた。元の投稿には「販売期間:8月17日〜8月23日」と書いてある。

抽出されていたエントリ(修正前)
{
  "id": "goods-kurimanju-curry-202608",
  "kind": "goods",
  "title": "くりまんじゅう(カレー)",
  "starts": { "on": "2026-08-17", "precision": "day" },
  "ends": null,
  "channels": [{ "name": "ちいかわ焼き", "starts": { "on": "2026-08-17", "precision": "day" } }]
}

ends null になっている。原因は自分で書いたドキュメントで、データモデルの文書に「ends は基本 null(在庫限りで、終了日は外から分からない)」という一文があった。収集器はそれを ends: null のハードコードとして実装しており、商品の抽出プロンプトはそもそも終了日を訊いていなかった。

気づけなかったのは、これが何も壊さないからだ。例外は出ない。npm run validate も通る。スキーマ上 endsnullable が正当な値で、「終了日が未発表」と「そもそも訊いていない」が同じ null で表現される。ただ、日付軸が芯のサイトに、7日で終わる商品が終わりの無い商品として載る。このパイプラインが繰り返し踏んできたのは落ちる故障ではなく、成功したまま何も取れていない故障のほうで、これもその一例だった。

2つの誤りは同じ根から出ている。「goodsは在庫限りのキャラクター雑貨」という一枚の絵だ。カードのラベルが「グッズ発売」になったのも、終了日の既定が null になったのも、その絵から出ている。7日間だけ売る食品は、その両方を同時に破る反例だった。直し自体は、プロンプトに終了日を訊く項目を足して ends: draft.ends ?? null に変えるだけで済んでいる。「在庫限り」「なくなり次第終了」は終了日ではないので null のままにした。

まとめ

discriminatedUnion の判別子は、型として「どのフィールドが必須か」を決めるための軸だ。それが分類名でもあるので、絞り込みの要求が来ると同じ軸を増やして応えたくなる。だが読者が絞りたい軸がそれと直交しているなら、値をいくつ足しても表せない。既存データが軸を跨いでいるかどうかは、机上の議論ではなく手元のデータを数えれば分かる。

もう1つ。ドキュメントに書いた既定は、コードの中でハードコードとして生き延びる。「基本 null」は観測の要約であって仕様ではなかったのに、実装はそれを仕様として読んだ。しかも反例が来ても例外は出ず、検証も通る。自分で書いた前提ほど、それが前提であることを忘れやすい。

zodTypeScriptData ModelingLLM