先に知っておくとよい概念
本文に何度も出てくる言葉を先にほどいておきます。
- union(ユニオン) — 「この値は A か B のどちらかだ」と並べたリストです。
'orderNo' | 'total'のような形ですね。許可する値を並べておけば、タイプミスがコンパイル時に引っかかります。 - 合成コンポーネント(compound component) —
Table.Root、Table.Bodyのように複数のパーツを組み立てて使うコンポーネントの集まりです。オプションを boolean prop でどんどん増やす代わりに、必要なパーツだけ選んで配置できます。 - widening(広げること) — TypeScript が値から型を決めるときに、あえてより緩い型に引き上げてくれる動きです。あとで値を入れ替えることを見越した気づかいなのですが、正確なリストを取り出したいときには邪魔になります。
- 変性(variance) —
DogがAnimalの一種であるとき、Dog[]やDogを受け取る関数もその関係を引き継ぐのかを決めるルールです。
今日やったこと
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| ソートキーの型を固める | カラム名のタイプミスが実行時まで届くのを防ぎたかった | カラム配列から union を導出 + const 型パラメータ | カラムを消すと関連する呼び出し側が全部コンパイルエラー |
| 行の型を渡す | ドメインごとに違う行の型をパーツまで届けたかった | ジェネリック関数の中で context を作りクロージャで捕まえる | as なしで型が生きたまま伝わります |
| 変性を掘り下げる | 制約をどちらにするかの根拠がほしかった | メソッド短縮記法と関数プロパティのチェックの厳しさの違いを確認 | 変性の判定に頼らない構造的な制約を選択 |
| 証拠を残す | 「型で防いだ」を検証可能にしたかった | コンパイルできてはいけない使い方を型テストで固定 | 保護が消えるとテストが赤くなります |
1. カラム配列からソートキーを取り出す
テーブルコンポーネントを作っていたのですが、「今どのカラムでソート中か」を持つ値がただの string でした。それだと sort.columnId = 'ordreNo' のようなタイプミスをしてもコンパイラは何も言ってくれません。実行してみて初めてソートが効かないことに気づくわけです。
かといって、こう手で書くと別の問題が起きます。
const columns = [{ id: "orderNo" }, { id: "total" }];
type ColumnId = "orderNo" | "total"; // 手で書いた複製
同じ事実を2か所がそれぞれ所有することになります。カラムを1つ消したのに union だけ残っていると、型は通って画面だけ壊れます。
そこで、カラム配列そのものを正解の出どころにして、union をそこから計算することにしました。
type ColumnIdOf<Cols extends readonly { id: string }[]> = Cols[number]["id"];
Cols[number] が配列の中のすべての要素の型をまとめ、そこに ["id"] をかぶせると各メンバーに分配されて、すべての id の union が出てきます。
ところが、これだけでは動きません。 ここが今日いちばん驚いた地点です。
const cols = [{ id: "orderNo" }, { id: "total" }];
type Bad = (typeof cols)[number]["id"]; // string
const で宣言したのにオブジェクトのプロパティは広がります。 なので結果が string になります。エラーも出ず、保護だけが静かにゼロになるわけです。これを知らないまま「型で防いだ」と思って進んでいたら、何も防げないコードをデプロイしていたでしょう。
防ぐには関数側で強制する必要があります。
function defineColumns<const T extends readonly { readonly id: string }[]>(cols: T): T {
return cols;
}
呼び出し側で as const を使う方法もありますが、それは利用者が書き忘れると同じように静かに無力化されます。公開 API なら関数が強制する側が正しいです。
制約に readonly が必ず入らないといけないことも学びました。const の推論結果が読み取り専用のタプルなので、制約がただの配列だとコンパイルに失敗します。これは幸いにも派手に失敗してくれるので、すぐ気づけます。
→ 値から型を導出する — indexed access と const 型パラメータ
2. ドメインごとに違う行の型をパーツまで届ける
テーブルを複数のドメインで再利用する必要があったのですが、ドメインごとに1行(Row)の形が違います。この型を context に載せて運ぼうとしたら、構文に止められました。
function f<T>(x: T) {} // ✅ 関数は型パラメータを取れます
type Box<T> = { v: T }; // ✅ 型エイリアスも取れます
const Ctx<T> = createContext<T>(null); // ❌ const 宣言は型パラメータを取れません
createContext(...) は型ではなく値です。呼んだ瞬間に型が1つに確定します。
回避策の候補を2つ、先に落としました。unknown にして消費地点ごとに as で変換すると、パーツの中にドメインの型を埋め込むことになり、再利用の目的が消えます。ドメインごとにファイルをコピーすればロジック全体が重複します。
答えは、ジェネリックな関数の中で context を作り、一緒に作ったコンポーネントたちがクロージャでそれをつかむ形でした。
export function createDataTable<Row>() {
type ContextValue = { columns: readonly ColumnDef<Row>[]; getRowId: (row: Row) => string };
const Ctx = createContext<ContextValue | null>(null);
const useTable = () => {
const c = useContext(Ctx);
if (!c) throw new Error("Table part must be used inside Table.Root");
return c;
};
function Body() {
const { columns, getRowId } = useTable(); // as なしで Row が生きています
/* ... */
}
return { Root, Head, Body }; // Ctx 自体はエクスポートしません
}
1回の呼び出しがそのまま独立したテーブル一式になります。別のファクトリのパーツを混ぜると実行時に throw し、別ドメインのカラムを渡すとコンパイルで止まります。
ここで出会った落とし穴が1つあります。ファクトリをコンポーネントの本体の中で呼んでもエラーになりません。 context の接続も正常です。Provider と consumer が同じクロージャから出ているからです。
本当の問題は、レンダーのたびにコンポーネントが新しい関数 identity を持つことです。React はそれを別のコンポーネントとみなして、アンマウントして再マウントします。 症状がエラーではなく「スクロール位置と入力値が消え続ける」なので、原因を探すのがずっと難しくなります。
3. 制約をどうするか悩んで変性まで
制約を readonly ColumnDef<never>[] にするか readonly { readonly id: string }[] にするかで悩みました。前者でも動くのですが、動く理由が引っかかりました。「cell コールバックが反変と判定されるから」だったのです。
そこで変性をちゃんと見ました。関数のパラメータが反変である理由は、思ったより直感的です。より広く受け取れる関数は、狭い場所に入れても安全だからです。
const handleAnyAnimal = (x: Animal) => console.log(x.name);
const handleOnlyDog = (x: Dog) => x.bark();
const h1: (x: Dog) => void = handleAnyAnimal; // ✅ 犬を渡しても大丈夫です
const h2: (x: Animal) => void = handleOnlyDog; // ❌ 猫が来たら bark() がありません
そして決定的なものを見つけました。strictFunctionTypes を有効にしても、メソッド短縮記法は例外です。
interface WithMethod {
on(cb: (e: Dog) => void): void; // 二変 — 緩いです
}
interface WithProperty {
on: (cb: (e: Dog) => void) => void; // 反変 — 厳しいです
}
読んだ感じはほとんど同じ2つの宣言で、チェックの厳しさが違います。つまり cell をメソッド短縮記法に書き換えた瞬間、私の制約が通っていた根拠が崩れるわけです。構文の一文字に保護がぶら下がっているようなものですね。
なので、変性の判定に頼らない構造だけを要求する制約を選びました。「id を持つ読み取り専用オブジェクトの読み取り専用配列」であれば十分ですし、これはどの構文で書いても揺らぎません。
4. 「型で防いだ」を検証可能に
1番で経験したことがずっと引っかかっていました。保護が消えても何の音もしない、ということです。
そこで、判定の質問を1つに整理しました。
書けてしまっていた間違ったコードのうち、今は何がコンパイルできなくなりましたか?
この質問に具体的に答えられない型の複雑さは入れないことにしました。答えられるなら、その答えをそのままテストとして残します。
// positive — 正常な呼び出しが型引数なしで推論されます
const ok: SortKey = "total";
// negative — 誤用1つにつき1行
// @ts-expect-error タイプミス
const typo: SortKey = "ordreNo";
@ts-expect-error が @ts-ignore と違う点が肝です。エラーが消えるとこの行が「使われていない」として赤くなります。保護がなくなること自体が失敗になる構造です。
ここでもう1つ学びました。テストを作って終わりではなく、そのテストが生きているかを確認しないといけません。const 型パラメータからわざと const を外してみて、テストが RED になるか見ました。赤くならないなら、そのテストは何も守っていないということですから。
ケースは境界で選びました
最初は否定ケースを**「最低3個」と決めていました。ところが書いてみると、3つとも同じ境界**をつついていたのですね。カラム id のタイポを3回書いていました。個数は埋めたのに、捕まえているものは1つでした。
そこで実行時テストで使っていた**境界値分析(BVA)**をそのまま型に移してみました。「最低2文字」なら1・2・3を見るように、です。ただし型の境界は値の大きさではなく、型の格子の極端です。
今日作ったカラムの型に実際に効いた境界は3つでした。
// 境界1. union メンバー / 非メンバー
const ok: SortKey = "total";
// @ts-expect-error 存在しないカラム
const bad1: SortKey = "userId";
// 境界2. リテラル / 広がった string ← 1番の作業で静かに崩れていたその地点
// @ts-expect-error すでに string に広がった値
const bad2: SortKey = "total" as string;
// 境界3. 推論の権限 — const パラメータが本当にリテラルを掴んでいるか
const cols = defineColumns([{ id: "orderNo" }, { id: "total" }]);
optional・readonly・tuple の長さ・never/any/unknown の軸はこの API には該当しないので作りませんでした。閉じてもいない境界にケースを埋めるのは、また個数埋めですから。
そしてもう1つ学びました。BVA の4つの軸のうち、型が覆えないものがあります。 時間・順序と副作用の回数です。今日は型設計だけだったのでその2軸には手も付けられておらず、ランタイムテストへ回す分として別に書き留めておきました。
今日の一行
型で防いだという主張は、「今は何がコンパイルできなくなったのか」に具体的に答えられるときだけ本当です。そしてその答えは、構文のひとかけらが欠けるだけで何の警告もなく偽になります。今日学んだ3つ — 導出と const がセットだということ、ファクトリをレンダー中に呼ぶと静かに再マウントすること、メソッド短縮記法がチェックを緩めること — は全部、失敗が騒がしくないという共通点を持っています。