定義
値から型を導き出すとは、許可値のリストを型として一度、値としてもう一度書くのではなく、値ひとつだけを正解の出どころにして、そこから型を計算することを指します。
道具は二つセットです。値から型を取り出す構文(indexed access、[number])と、その値が広がらないように押さえる仕組み(as const、const 型パラメータ)です。どちらか一方だけでは機能しません。
なぜ必要なのか
同じ事実を二か所が持つと、必ずズレます。テーブルコンポーネントのカラム一覧を例にしてみます。
const columns = [{ id: 'orderNo' }, { id: 'total' }];
type ColumnId = 'orderNo' | 'total'; // 手書きの写し
ここで total カラムを消すとします。配列からは消したのに union がそのまま残っていると、sort.columnId = 'total' と書いたコードが何の警告もなく通ってしまいます。実行して初めてソートが効かないことに気づきます。
逆に union だけ直して配列を直さないと、画面にはカラムが残っているのに、それでソートする手段がなくなります。
導出に変えると、カラムを消した瞬間にその id でソートしていたすべての呼び出し箇所がコンパイルエラーになります。ここが手書きの union との決定的な違いです。手書きの union は、union だけ直して使用箇所を見落とします。
動作の仕組み
ステップ1: 値から型を取り出す
**indexed access(インデックスアクセス型)**は、オブジェクト型からプロパティの型だけを取り出す構文です。ドット記法はなく、常に角括弧を使います。
type User = { id: string; name: string };
type Id = User['id']; // string
[number] を配列型に付けると、その配列に入りうるすべての要素型を union にまとめてくれます。
type Pair = [string, number];
type Element = Pair[number]; // string | number
keyof を使ってはいけない理由がここにあります。keyof Pair は '0' | '1' | 'length' | 'map' | ... のように配列自体のプロパティまで全部出てきます。
union に indexed access を被せると分配されます。 (A | B)['id'] は A['id'] | B['id'] になります。この三つを組み合わせると、欲しいものが出てきます。
type ColumnIdOf<Cols extends readonly { id: string }[]> = Cols[number]['id'];
ステップ2: 値が広がらないように押さえる
ここからが本番です。TypeScript は値を見て型を決めるとき、わざともっと緩い型に引き上げます。これを widening(広がり) と呼びます。
const a = 'orderNo'; // 'orderNo' ← 変数自体はリテラルのまま
const obj = { id: 'orderNo' }; // { id: string } ← ⚠️ プロパティは広がります
const cols = [{ id: 'a' }, { id: 'b' }]; // { id: string }[] ← タプルでもリテラルでもありません
const で宣言してもオブジェクトのプロパティは広がります。あとで obj.id = '別の値' ができるのだから当然の配慮ではあるのですが、正確なリストを抜き出したいときには致命的です。
広がった状態でさっきの導出を走らせると、こうなります。
type Bad = (typeof cols)[number]['id']; // string
エラーも出ず、保護だけが静かにゼロになります。 これがこのパターン最大の落とし穴です。型は通り、タイポはそのままランタイムまで届きます。
ステップ3: 防ぐ方法は二つ
// (a) 呼び出し側で as const
const cols = [{ id: 'orderNo' }, { id: 'total' }] as const;
// (b) const 型パラメータ (TypeScript 5.0+)
function defineColumns<const T extends readonly { readonly id: string }[]>(cols: T): T {
return cols;
}
const cols = defineColumns([{ id: 'orderNo' }, { id: 'total' }]);
(a) は利用者が as const を書き忘れると、何の警告もなく無力化されます。(b) は関数側が強制するので、書き忘れようがありません。公開 API なら (b) を選びます。
制約には必ず readonly を入れてください。const 推論の結果が読み取り専用タプルなので、制約がただの配列だと「読み取り専用を書き込み可能な場所に入れられない」でコンパイルに失敗します。
実務での適用
型引数を一部だけ指定したいとき
行(Row)の型は自分で指定して、カラム配列は自動推論させたい、という状況はよくあります。ところがTypeScript には型引数を一部だけ指定する構文がありません。
// ❌ Row だけ渡して T は推論させる、ができません
function defineColumns<Row, const T extends readonly ColumnDef<Row>[]>(cols: T): T;
defineColumns<OrderRow>(cols); // T まで要求されます
関数を二段に分けて(カリー化)階層を分離します。
function defineColumns<Row>() {
return <const T extends readonly ColumnDef<Row>[]>(cols: T): T => cols;
}
const cols = defineColumns<OrderRow>()([
{ id: 'orderNo', header: '注文番号' },
{ id: 'total', header: '合計' },
]);
type SortKey = ColumnIdOf<typeof cols>; // 'orderNo' | 'total'
同じ手法が通用する別の場面
// ルート一覧からパスの union を導出
function defineRoutes<const T extends readonly string[]>(paths: T): T { return paths; }
const routes = defineRoutes(['/home', '/settings']);
type Route = (typeof routes)[number]; // '/home' | '/settings'
// オブジェクト定数からキーの union を導出
const THEME = { light: '#fff', dark: '#000' } as const;
type ThemeName = keyof typeof THEME; // 'light' | 'dark'
検証の方法
導出結果の型にマウスを乗せてみてください。string が見えたら、どこかで広がっています。 原因はたいてい次のどちらかです。
- 配列リテラルを一度変数に入れてから渡した
- すでに広がった配列を spread で合成した(
[...base, extra])
const 型パラメータは呼び出し側に直接書いたリテラルだけを生かします。すでに string に広がった変数は生き返らせられません。
トレードオフ
導出が常に勝つわけではありません。
得られるものは、正解が一か所にしかないという保証です。カラムを消せば関連する呼び出し箇所が全部赤くなるので、半分だけ直すという事故が構造的に起こりえなくなります。
支払う代償は、型シグネチャが読みにくくなることです。<const T extends readonly { readonly id: string }[]> は初心者が見ると身構えます。さらにカリー化が入ると、呼び出し構文も defineColumns<Row>()(cols) のように見慣れないものになります。
リストが短くてめったに変わらず、使用箇所が一つのファイル内に収まるなら、手で union を書くほうがましです。判断基準は**「このリストを直すとき、他のところも一緒に直す必要があるか」**です。答えがイエスなら導出し、そうでなければそのまま書きます。
使ってはいけない場合
- リストがランタイムに決まる場合。 サーバーのレスポンスでカラムが降ってくるなら、コンパイル時に union は作れません。これは型ではなくランタイムパーサーの領域です。
- 利用者が配列を組み立てて渡す API。 配列を変数にしたり spread で合成して渡す使い方が主流なら、
const型パラメータは何も生かせません。保護があるふりをするだけになります。 - 一か所でしか使わない短いリスト。 シグネチャの複雑さが、得られる安全性を上回ります。
よくある失敗
- 導出構文だけ使って
constを忘れる。 一番よくあって、一番静かな失敗です。結果がstringになるのにエラーは出ません。 - 制約に
readonlyを書き忘れる。 これは逆に騒がしく失敗します。「readonly を mutable に代入できない」というエラーが出ます。 - 配列リテラルを変数に入れてから渡す。
const raw = [...]; defineColumns(raw)はすでに広がった後なので復元できません。呼び出しの中に直接書く必要があります。 - 導出が効いているか確認しない。 検証の質問は一つだけです。「このカラムを消したら、どの呼び出し箇所がコンパイルエラーになりますか?」答えが「どこにも」なら、保護はありません。
- hover だけ見て安心する。 人が毎回 hover で確認するのは続きません。型テストで固定しておくのが正解です。
関連概念
- typescript-variance — 制約を書くときに変性の判定に頼ると、構文の変化で崩れる理由
- generic-context-factory — この導出を合成コンポーネントの部品まで伝える方法
- type-level-testing — 導出が静かに無力化されるのを捕まえる唯一の手段
- single-source-of-truth-content-metadata — 同じ事実を二か所が持つとズレる、という同じ原理