TypeScript を文法で覚えるのをやめて、型システムそのものを理解した話
はじめに
TypeScript を使っていると、ある程度までは“文法を覚えるだけ”でなんとなく書けてしまう。
型注釈つけて、エラーが出たら修正して、as で無理やり通して……気づいたら動くコードは書けるようになる。
ただ、そんな状態で開発を続けていると、必ずこういう壁にぶつかる。
- 今なぜこの型エラーが出るのか説明できない
- Union 型が広がり続けて収拾がつかない
- 推論が意図と違う方向に転がる
- API レスポンスの型が気づいたら壊れている
-
unknownとanyの正しい使い分けができない
自分もずっとこの“型のブラックボックス感”にモヤモヤしていた。
「書けるけど、本質を理解していない感」があるというか、
“型に振り回されている”時間が長かった。
そこで今回、TypeScript の型システムの裏側を体系的に学び直してみた。
実際に学んでみると、
「なぜその型になるのか?」を説明できるようになると一気に開発が安定する
ということをめちゃくちゃ実感した。
この記事では、以下のテーマで TypeScript の「型の裏側」を分解する:
- TypeScript が採用する「構造的型付け」
- 型チェックは「代入可能性(assignability)」を基準に動いている
- 推論の“広がる / 固まる”挙動
- Union 型が暴走する理由と防ぎ方
- 関数型に影響する「変性(variance)」
-
satisfiesが何を解決するのか
TypeScript を“文法”で覚えてきたけど、そろそろ中級に進みたい。ということで、実務で効く学びにフォーカスして備忘録としてまとめた。
1. TypeScript は“構造的型付け”
TypeScript の型システムを理解するうえで、最初に押さえておくべき前提がある。
それが 「TS は構造的型付けで動いている」 という点だ。
Java や C# のような「名義的型付け」では、
型の“名前”が同じかどうかの判断基準になる。
一方で TypeScript は、
“型定義の名前”ではなく“構造”を見て型の互換性を判断する。
同じ構造なら、名前が違っても代入できる。
この性質が、のちの「代入可能性」や「Union の広がり」に深く影響する。
1-1. 同じ構造なら、別名でも“同じ型”とみなされる
type User = {
id: number;
name: string;
};
type Customer = {
id: number;
name: string;
};
const user: User = { id: 1, name: "Alice" };
const customer: Customer = user; // OK:構造が完全に一致しているため
User と Customer は名前は違うが、
プロパティの構造が一致しているため、TS では同一人物としてみなされる。
1-2. “不要なプロパティがあっても”代入可能になる
構造的型付けでは、
「必要な構造を満たしていれば OK」 という方向の柔軟性がある。
type A = { x: number };
type B = { x: number; y: string };
const b: B = { x: 1, y: "hi" };
const a: A = b; // OK:A が必要とする要素は全部あるため
この柔軟性が実務ではかなり便利だが、一方で「勝手に型が許容されてしまう」ケースも生む。
1-3. 逆はダメ。「必要なものが足りない」場合は NG
type A = { x: number };
type B = { x: number; y: string };
const a: A = { x: 1 };
const b: B = a; // エラー:B に必要な y がない
構造的型付けの判断基準は非常にシンプルで、
「受け取る側が必要とする構造を全て持っているか」
だけ。
1-4. この性質が“型推論の広がり”や“代入可能性”の根本になる
TypeScript の型チェックの起点は
「ある型が別の型に代入できるか?」
にある。
構造的型付けで“構造が一致していれば代入できる”というルールがあるため、
たとえばこの後まとめる:
- Union 型が“広がる”
- Conditional Types が分配される
- 関数の引数が“反変”で扱われる
- API 型がすぐ崩れる
といった挙動にも深く関わってくる。
2. TypeScript の型チェックは「代入可能性」で動く
TypeScript の型システムを理解する上で、最も重要と言っていい概念が 「代入可能性」 だ。
型チェックは表面的には
「型が一致しているか?」
「型注釈に合っているか?」
のように見えるが、実際に TS が内部で判断しているのはもっとシンプルだ。
“ある値(型)が、別の型に代入できるかどうか”
この一点。
そしてこの判断は前の章で説明した 構造的型付け と密接に繋がっている。
2-1. 代入可能性の基本ルール
必要なプロパティが揃っていれば代入可能。足りなければエラー。
type A = { x: number };
type B = { x: number; y: string };
const a: A = { x: 1 };
const b: B = { x: 1, y: "hi" };
let v1: A = b; // OK(構造が A を含んでいる)
let v2: B = a; // NG(必要な y がないため)
これは「構造的型付け」そのもの。さっきも出てきた。
2-2. “extends” も実は「代入できるか?」の判定にすぎない
TS 初心者が誤解しがちポイントだが、T extends U は「継承」ではない。
実際には:
「T が U に代入できるなら true、それ以外なら false」
を返す条件式として機能する。
type IsNumber<T> = T extends number ? "yes" : "no";
type A = IsNumber<number>; // "yes"
type B = IsNumber<string>; // "no"
つまり、TS の conditional type は代入可能性ベースの条件分岐 に過ぎない。
Java や C# を勉強している人ほどつまづきやすいポイントだ。
2-3. 代入可能性は“型推論”にも強烈に影響する
たとえば TS は「より安全な型」を優先する。そのため Union を作ると、代入可能性ベースで infer が変化する。
const value = Math.random() > 0.5 ? 1 : "hi";
// 推論される型:string | number
なぜ Union になるかというと、
値 1 と "hi" の両方が代入できる“共通の最小上界” が求められるため。
(大学数学以外で"最小上界"という言葉を自分が使うことになるとは思わなかった...)
2-4. 関数の型チェックで“反転”が起きる理由もこれ
関数の型チェックがややこしく見える理由は、
引数の代入可能性が反転する(反変性) という性質があるから。
例:
type FnA = (value: string | number) => void;
type FnB = (value: string) => void;
let a: FnA;
let b: FnB;
a = b; // OK
b = a; // NG
理由はシンプルで、TS は
「受け取る側が求める型に対して、渡す側が代入可能か?」
を判定しているだけだから。
→ これについては次の変性が関わっている。
2-5. 実務で代入可能性を意識するとどう変わる?
API レスポンスや UI 状態など、複雑な型が絡む時ほどこの理解が効く。
- この型はこの関数に渡していいのか?
- こっちの型に代入すると何が失われるのか?
- Narrowing すべきか?
- Literal を保持すべきか?
こうした判断が直感的にできるようになってくる。
3. 型推論には「広がる / 固まる」の2種類の挙動がある
TypeScript の型推論はとても賢く見えるけど、
実際のところ挙動は シンプルな2つの方向性で動いている。
- Widening(広がる)
- Narrowing(固まる)
この2つのメカニズムを理解しておくと、
「なんでこの型になるの?」という疑問の大半が説明できる。
3-1. 推論はまず “広がる(widening)” のが基本
TypeScript は、リテラル値から推論するときより広い型に“勝手に”拡張しようとする。
例:
const a = "hello";
// a: string
「"hello" というリテラル型」ではなく、もっと広い string と推論される。
これは TS が「柔軟に扱える型を優先する」という設計思想のため。
他にも:
let x = 1;
// x: number
const arr = [1, 2];
// arr: number[]
リテラル型のまま保持してほしい場合は、後述する as const が必要。
3-2. as const は「リテラルを固める」最強の手段
推論の“広がり”を止めたい場合、
as const を使うとリテラル型が保持されたまま固定される。
const a = "hello" as const;
// a: "hello"
オブジェクトも同様:
const config = {
mode: "dev",
retry: 3,
} as const;
// {
// readonly mode: "dev";
// readonly retry: 3;
// }
リテラル型を保持することで、より型安全なコードが書ける。
3-3. “Narrowing(絞り込み)” は条件分岐で起きる
TypeScript は条件分岐の中で自動的に型を絞り込む。
function printId(id: string | number) {
if (typeof id === "string") {
id.toUpperCase(); // ここでは string と推論される
} else {
id.toFixed(2); // ここでは number と推論される
}
}
これが コントロールフロー型解析 と呼ばれる TS の強み。
3-4. 推論の「広がり」が“意図しない Union 型膨張”を起こす
現場のコードでもありがちだが、
本来 "approved" | "pending" | "rejected" のように 特定の文字列だけを許可したいケースがある。
たとえば、ユーザー申請のステータスを持つ変数を考える。
本来欲しい型はこれ:
"approved" | "pending" | "rejected"
しかし、次のように書くとどうなるか。
let status = "approved";
status = "pending";
status = "rejected";
一見、Union 型になりそうに見えるが…
status: string
と推論されてしまう。
なぜ "approved" | "pending" | "rejected" にならず、string になるのか?
理由はシンプルで、TypeScript の推論は リテラルを保持しないで“広い型”に広げようとする(widening) ため。
つまり TS はこう判断している:
- "approved" → とりあえず string として扱おう
- "pending" → string はこれも入る
- "rejected" → string はこれも入る
結果:
status: string
になる。
この“広がり”が実務で地味に危険
status が string になってしまうと、本来ありえない値も簡単に通ってしまう。
status = "super-admin-approved"; // 本来エラーにしたいのに通ってしまう
status = "wtf"; // もちろん通る
こうなると、「ステータスが3種類しかない」という制約が型として保証されない。
静的解析で守れるはずの設計が崩れてしまう。
本来こうしたかった
let status: "approved" | "pending" | "rejected" = "approved";
status = "rejected"; // OK
status = "wtf"; // エラー
これが TypeScript の型安全性を最大化した状態。
どう防げばいいのか?
この問題の根本原因は “勝手に string に広がる” こと。
防止する方法はいくつかある。
1. as const を使う
let status = "approved" as const;
// status: "approved"
ただし、これだとリテラルが固定されてしまうので Union にはなりにくい。
2. 最初から型注釈を書く(実務で一番現実的)
let status: "approved" | "pending" | "rejected" = "approved";
3. 配列+as const で Union を作る
const StatusList = ["approved", "pending", "rejected"] as const;
type Status = typeof StatusList[number];
let status: Status = "approved";
実務的にはこの方法が安定しててよさそう。
4. satisfies で “構造だけ確認して推論を壊さない” 書き方
const status = "approved" satisfies
| "approved"
| "pending"
| "rejected";
これも読みやすいし、型推論が壊れない。
3-5. 実務でよく起きる “広がり事故”
API レスポンスをパースするコードで特に起きやすい。
const status = response.status; // 本来は "success" | "error" | "pending" のような Union 型
しかし、実際の推論は次のようになってしまう。
status: string
これは、文字列リテラルが string に「広がる」ため。結果として、本来ありえない値も静的に弾けなくなる。
status = "superFail"; // 本来エラーにしたいのに通ってしまう
この問題に強く効くのが、あとで紹介する satisfies。
3-6. 推論の「広がる / 固まる」を知ることで防げるトラブル
- 本来の
"a" | "b" | "c"や"approved" | "pending" | "rejected"といった Union 型が維持されない問題 - API レスポンス型が
stringに広がり、型安全性が失われる - 状態管理の変数が意図せず “何でも入る” 型に広がってしまう
- コンポーネント props が気づかないうちに string / number に拡大される
TypeScript の型崩壊の多くは、「推論が勝手に広がる」挙動をコントロールできていないことが原因。
了解、あさひ。
じゃあ セクション4:「Union 型が“暴走”する本当の理由(分配的条件型)」 を Zenn にそのまま貼れるレベルで書き切るよ。
ここは TypeScript の“中級者の壁”と言っていい部分で、
「型が思ったより増える」「なんでこの Union になるの?」
という現場の謎の多くがここに集約される。
わかりやすく、でも中級者っぽく書いていく。
4. Union 型が“暴走”する理由は「分配的条件型」
TypeScript を触っていると、「Union 型が勝手に膨れ上がってカオスになる現象」に出会うことがある。
これ、実は TypeScript の条件型の仕様によるもの。名前は堅いけど、本質はシンプル。
4-1. まずは基本の条件型
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
これは「T が string に代入可能なら true」という単純な条件式。
4-2. ここまではOK。問題は “左辺が Union 型” の時
次のコードを見てほしい。
type Check<T> = T extends string ? "yes" : "no";
type R = Check<string | number>;
Java っぽく考えると
「string | number が string に代入可能か?」 → no
と一発で判定されそうだが、TypeScript はそうしない。
TypeScript の結果はこう:
// R は "yes" | "no"
Union が分割されて評価されている。なんでやねん。
4-3. これが「分配的条件型」
TypeScript の仕様では:
T が Union 型の場合、T extends U ? ... : ... は T の要素ごとに分配される。
つまり中身で枝分かれする。
実際の内部挙動はこう:
Check<string | number>
→ Check<string> | Check<number>
→ ("yes") | ("no")
これが Union 型が“勝手に増える”理由。
4-4. 実務でよくある「意味不明な型」も実はこれ
たとえば「型のキーだけ取りたい」時のやつ:
type Keys<T> = T extends any ? keyof T : never;
これを Union に適用すると…
type Obj = { id: number } | { name: string }
type K = Keys<Obj>
K はこうなる:
// "id" | "name"
表面的には「なんで?」と思うが、分配されているだけ。
内部:
Keys<{id: number}> → "id"
Keys<{name: string}> → "name"
なので単純に Union になっている。
4-5. “Union の暴走”を止める方法もちゃんとある
【方法1】タプルで囲んで分配を止める
分配は “素の型”の状態で extends するとき に起きる。
だから 配列(タプル)に包むと止まる。
type Check<T> = [T] extends [string] ? "yes" : "no";
type R = Check<string | number>;
// "no"
内部で分解されず、“全体として”比較される。
【方法2】Union のまま扱わず、事前に Narrow する
Union が大きくなる原因は「なんでも入る型」を許容していること。
なので、実務では次のように扱うのが定石:
type Status = "approved" | "pending" | "rejected";
function isApproved(s: Status) {
return s === "approved";
}
中で勝手に膨らまない。いままで当たり前に書いてたけど、こうやって詳しく見ると面白い。
【方法3】型を “構造” でまとめて分配を意図的に利用する
逆に、この性質をうまく使うと超有用。
type Success = { status: "success"; data: string };
type ErrorRes = { status: "error"; message: string };
type Res = Success | ErrorRes;
type ExtractSuccess<T> =
T extends { status: "success" } ? T : never;
type OnlySuccess = ExtractSuccess<Res>;
// { status: "success"; data: string }
これが TS の高度な型操作の基本になる。
4-6. まとめ:Union 型の暴走は TypeScript が“親切すぎる”ことが原因
- TS は Union 型に対して条件型を書くと 全パターンを展開してしまう
- これが「Union 型が勝手に増える」ように見える
- 分配的条件型という仕様で、避けるにはタプルで包む必要がある
- 実務では“不要な Union を作らない設計”の方がもっと大事
この仕様を理解すると、「なんでこんな謎の型になるん?」が一気に減る。なぞの型エラーの原因が一部解明できた気がする。
5. 関数型に深く効く「変性」の話
TypeScript の型挙動で「なんでこれエラー?」「なんでこれは OK?」の典型例が、
関数の代入に関わる部分。
そしてその原因が 変性 だ。
変性は難しそうに見えるけど、実は本質はシンプルで、
「どの方向に代入可能なのか?」
という話。
5-1. 変性には4種類ある
一般的な型システムでは、型パラメータの関係に次の4パターンがある:
| 名称 | 意味 |
|---|---|
| 共変 | T が U に代入できるなら、F<T> も F<U> に代入できる |
| 反変 | T が U に代入できるなら、F<U> が F<T> に代入できる(逆方向) |
| 不変 | どちらにも代入できない |
| 双変 | 両方向に代入できる(TS 独自の緩い仕様) |
ここでは、実務で最も重要な 共変・反変 に絞って扱ってみる。
5-2. 値の型は「共変」:直感に近い
まずは簡単な方。
type A = string;
type B = string | number;
string は string | number に代入可能なので…
let a: A = "hi";
let b: B = a; // OK
これは共変。
値の型が自然に“広い型”の方へ入ることを許容する。
5-3. しかし「関数の引数」は“逆方向”になる(反変)
問題はこっち。
次の例を見てほしい。
type FnA = (v: string | number) => void;
type FnB = (v: string) => void;
let a: FnA;
let b: FnB;
a = b; // OK
b = a; // エラー
直感的には「同じように見えるのになぜ?」と思うが、理由は “引数は反変” だから。
どういうことか?
- FnA は “string か number” を受け取れる関数
- FnB は “string だけ” を受け取れる関数
ここで、b を a に代入する(a = b)ということは:
「string | number を受け取れるはずの関数」に、
「string しか受け取れない関数」を渡す
これは安全。
でも逆は?
「string しか受け取れない関数」に、
「string | number を受け取る可能性のある関数」を渡す
これは事故の元なので TS はエラーにする。
5-4. 絵で見ると一気にわかりやすい
value の場合(共変)
string → string | number (広い型に代入OK)
function の引数(反変)
(string | number) → string (狭い型の方に代入OK)
つまり、引数は
“より狭い型を受け取る関数の方が安全”
という思想。
5-5. 実務でどう効いてくるのか?
ケース1:コールバック関数を渡すところでよく発生
function handle(callback: (status: "ok" | "ng") => void) {}
const cb = (status: "ok") => {}; // もっと狭い型
handle(cb); // OK(反変だから)
これは反変が効いているため。
もしこれが共変だったら、cb が "ng" を受け取れずクラッシュする可能性がある。
ケース2:イベントハンドラの型が崩れる問題
React や DOM API のイベント型も、反変の影響で説明できる。
type MyHandler = (e: MouseEvent) => void;
const handler: (e: Event) => void = () => {};
const a: MyHandler = handler; // TS的にはOK
Event → MouseEvent は狭くなる方向なので安全。
5-6. TypeScript の特殊性:関数は厳密には “双変” の場合がある
実は TypeScript の仕様は Java・Scala などより少し緩く、関数の引数は一部 双変 として扱われる。
これは「実務的に厳しすぎると困るから」という TS の割り切り。
ただし strict mode では反変寄りになるため、基本の理解としては「関数の引数は反変」で覚えるのが正しい。
5-7. まとめ:関数型の理解は“代入可能性”の理解でもある
- 値は共変(広い型に代入できる)
- 関数の“引数”は反変(狭い型に代入できる)
- React/イベント/コールバック地獄のエラーはだいたいここが原因
- TypeScript は一部双変だが、strict mode では反変寄り
ここを押さえておくと、関数型に絡む謎エラーの9割は説明できるようになる。
これで型エラーは怖くない。
6. satisfies が「型注釈」より圧倒的に強い理由
TypeScript 5.x 以降で特に評価が高いのが、satisfies 演算子の登場。
「型注釈書けるし、別にいらなくない?」
と思う人もいるけど(僕もちょっと思ってた)、実は satisfies は 型安全性と型推論の“いいとこ取り” ができる、超有能な仕組み。
実務で API 型や設定オブジェクトを扱うときに、“型が崩れる問題”を綺麗に解決してくれる。
6-1. 型注釈(: Type)と何が違うの?
まず比較するとわかりやすい。
型注釈では推論が消える
const config: {
mode: "dev" | "prod";
retry: number;
} = {
mode: "dev",
retry: 3,
};
型注釈を書くと、オブジェクト全体の型推論が殺される。
例えば config.mode を hover すると:
"dev" | "prod"
ではなく "dev" に固まる or 広い型になる挙動が変わる。
また、プロパティを追加すると TS は気づかない場合がある。
6-2. satisfies は「構造だけチェックして推論は壊さない」
const config = {
mode: "dev",
retry: 3,
} satisfies {
mode: "dev" | "prod";
retry: number;
};
この書き方だと:
- 構造は型定義に合っているかチェックされる(安全)
- でもオブジェクトの推論自体はそのまま維持される(便利)
という“両取り”が成立する。
つまり satisfies は:
「こいつはこの型を満たしてるよな?…OK。でも推論は自由にやっていいよ。」
という指示を TS に出している。便利じゃん。
6-3. 実例:よくある “型が広がって壊れる” 問題を防ぐ
たとえば API レスポンスのステータスをこう書いてしまうと:
const status = "approved";
// status: string
widening で勝手に string になってしまい、Union 型が維持されない。
それを satisfies で書くと:
const status = "approved" satisfies
| "approved"
| "pending"
| "rejected";
→ 推論は "approved" のまま保持
→ でも型は Union との整合性がチェックされる
→ "wtf" を入れると即エラーになる
推論を壊さずに安全性だけ確保できてる状態。
6-4. 配列でも活躍する:Union 型の維持が楽になる
const StatusList = ["approved", "pending", "rejected"] satisfies
readonly string[];
type Status = typeof StatusList[number];
こうすると:
- リテラル型が保持される
-
"approved" | "pending" | "rejected"を自動生成できる - 配列の中身を変えれば Union も勝手に変わる(メンテが楽)
この書き方は実務でめちゃくちゃ使われている。
6-5. ライブラリの options でも圧倒的に便利
例えば Next.js や ESLint、Tailwind など設定オブジェクトを扱う場面で satisfies は輝く。
const options = {
env: "dev",
cache: true,
} satisfies ConfigOptions;
env: "deev" のような typo が即座に検出されるし、cache の型が壊れることもない。
設定オブジェクトの安全性が一気に上がる。
6-6. satisfies を使うべき場面
- API レスポンスの型を扱うとき
- 設定オブジェクト(config)を書くとき
- Union 型を維持したいとき
- リテラル型を壊さずに安全性だけ確保したいとき
- オブジェクトの構造チェックをしたいけど推論は保ちたいとき
逆に、実務で型注釈を使う必要があるのは:
- クラスの実装
- Type の定義
- 関数引数や戻り値の明示的な契約
などの明確な“静的契約”が必要な場面。
6-7. まとめ:satisfies は「型安全性と推論の両方を救う」道具
- 型注釈では推論が壊れる
- 推論だけに任せると安全性が壊れる
-
satisfiesはその中間を完璧に埋める - 特に literal 型(ステータス / モード / config)と相性抜群
実務の型崩壊の多くが satisfies で解決する。
おわりに
ここまで、TypeScript の“型の裏側”を中心にまとめてきた。文法や tips の記事ではなく、型システムそのものに踏み込むことで、実務に直結する理解が得られたと思う。
もし「TypeScript が難しい」「型が謎」という人がいたら、ぜひ今回の内容のどれか一つでも触ってみてほしい。
「なんでこの型になるの?」が説明できるようになると、TS は一気に使いやすくなる。
よき TS ライフを!
Discussion