📁

TypeScript を文法で覚えるのをやめて、型システムそのものを理解した話

に公開

はじめに

TypeScript を使っていると、ある程度までは“文法を覚えるだけ”でなんとなく書けてしまう。
型注釈つけて、エラーが出たら修正して、as で無理やり通して……気づいたら動くコードは書けるようになる。

ただ、そんな状態で開発を続けていると、必ずこういう壁にぶつかる。

  • 今なぜこの型エラーが出るのか説明できない
  • Union 型が広がり続けて収拾がつかない
  • 推論が意図と違う方向に転がる
  • API レスポンスの型が気づいたら壊れている
  • unknownany の正しい使い分けができない

自分もずっとこの“型のブラックボックス感”にモヤモヤしていた。
「書けるけど、本質を理解していない感」があるというか、
“型に振り回されている”時間が長かった。

そこで今回、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:構造が完全に一致しているため

UserCustomer は名前は違うが、
プロパティの構造が一致しているため、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;

stringstring | 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 だけ” を受け取れる関数

ここで、ba に代入する(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