型システムの土台と設計思想

型を書く前に、なぜ型が必要なのかを知れ。それがTypeScriptを本当に使いこなす第一歩だ。


この記事で得られること

  • JavaScriptとTypeScriptの差分を言語仕様レベルで理解する
  • TypeScriptがあえて「完全な型安全」を諦めた理由を知る
  • 構造的型付けと名前的型付けの違いと、その設計的意図を理解する
  • 型推論がどのように動いているかを仕組みから理解する

Chapter 1: JSとTSの差分・完全解説

1-1. TypeScriptはJavaScriptの「完全なスーパーセット」

TypeScriptの最も重要な特性から始めます。

// 普通のJavaScriptはそのままTypeScriptとして動く
var x = 1;
const arr = [1, 2, 3];
function hello(name) { return `Hello, ${name}`; }

これは当たり前に見えて、革命的な設計判断です。既存のJSコードを一切変えずに .js.ts にリネームするだけで動く。この互換性保証があったからこそTypeScriptは世界中に普及しました。


1-2. TypeScriptが追加したもの

JavaScriptに対してTypeScriptが追加した要素は大きく4つです。

① 型アノテーション

// 変数・引数・戻り値に型を明示できる
const name: string = "Masaki";
const age: number = 30;

function greet(name: string): string {
  return `Hello, ${name}`;
}

② インターフェースと型エイリアス

// オブジェクトの形を定義する
interface User {
  id: string;
  name: string;
  email: string;
}

// Union型・交差型など複雑な型を定義する
type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: Error };

③ ジェネリクス

// 型を「パラメータ」として受け取る
function identity<T>(value: T): T {
  return value;
}

const num = identity(42);       // T = number と推論
const str = identity("hello");  // T = string と推論

④ 列挙型(enum)

// ただし enum には罠がある(後述)
enum Direction { Up, Down, Left, Right }

// 実務では as const を推奨
const Direction = { Up: "UP", Down: "DOWN" } as const;
type Direction = typeof Direction[keyof typeof Direction];

1-3. コンパイル後に型は「消える」

TypeScriptの型情報はランタイムに存在しません。

// TypeScriptのコード
function greet(name: string): string {
  return `Hello, ${name}`;
}

const user: { id: number; name: string } = { id: 1, name: "Masaki" };
// コンパイル後のJavaScript(型が完全に消える)
function greet(name) {
  return `Hello, ${name}`;
}

const user = { id: 1, name: "Masaki" };

これが意味することは「型チェックは開発時だけの安全網」です。実行時には型情報は存在しないため、外部APIのレスポンスなど「実行時に入ってくるデータ」には別途検証が必要になります(zodなどを使う理由はここにあります)。


1-4. JSにあってTSで変わる挙動

型強制(Type Coercion)はそのまま存在する

// JSの型強制はTSでもそのまま動く
console.log(1 + "2");   // "12"(数値 + 文字列 = 文字列)
console.log(true + 1);  // 2

// TSは型が合わない演算を一部検出できる
const x: number = 1;
const y: string = "2";
const z = x + y; // TSは警告しない(+演算子はstring/numberを許容)

TypeScriptは「JSと完全に互換」を優先するため、JSの動的な型強制をそのまま許容しています。

null と undefined の扱い

// strictNullChecks: true(推奨設定)の場合
function getName(user: User | null): string {
  return user.name; // ❌ コンパイルエラー:user が null の可能性がある

  // ✅ null チェックが必要
  if (user === null) return "Guest";
  return user.name; // ここでは User と確定
}

strictNullChecks を有効にすることで、nullやundefinedの扱いが格段に安全になります。


Chapter 2: TSがあえて諦めた型安全

TypeScriptには、意図的に型安全性を犠牲にしている部分があります。これを知らないと「なぜ型エラーが出ないのに実行時エラーが起きるのか」が理解できません。

2-1. any型:型システムの非常口

const data: any = fetchSomething();
data.nonExistentMethod(); // ✅ TSはチェックしない
data[0].foo.bar.baz;      // ✅ これも通る(実行時エラー確定)

any を使った瞬間、TypeScriptの型チェックが完全に無効化されます。

なぜ存在するのか? 段階的な型付け(Gradual Typing)のためです。既存のJSコードをTSに移行する際、全てを一度に型付けするのは現実的ではありません。any は「まだ型をつけていない部分」を一時的に許容するための脱出口として設計されました。

// ❌ any を使う
function processData(data: any) {
  return data.value; // 何が起きても気づけない
}

// ✅ unknown を使う(型チェックを強制できる)
function processData(data: unknown) {
  if (typeof data === "object" && data !== null && "value" in data) {
    return (data as { value: unknown }).value;
  }
  throw new Error("Invalid data");
}

2-2. 型アサーション(as):開発者の責任宣言

const input = document.getElementById("myInput");
// input は HTMLElement | null と推論される

// as で型を「断言」する
const value = (input as HTMLInputElement).value;

型アサーションは「私は型システムより正確な情報を持っている」と宣言する機能です。TSはこの断言を信頼し、間違っていても警告しません。

// 二重アサーションという「最終手段」もある(使用は最小限に)
const dangerous = someValue as unknown as string;

2-3. 配列アクセスの型安全性の欠如

const arr = [1, 2, 3];
const item = arr[10]; // TypeScriptは number と推論する
                      // 実際は undefined なのに...

console.log(item.toFixed(2)); // 実行時エラー!

これはTypeScriptが意図的に許容しているトレードオフです。全ての配列アクセスに undefined チェックを強制すると、コードが煩雑になりすぎます。

noUncheckedIndexedAccess オプションを有効にすると、配列アクセスの型に undefined が含まれるようになります:

// tsconfig: noUncheckedIndexedAccess: true
const arr = [1, 2, 3];
const item = arr[0]; // number | undefined と推論される
if (item !== undefined) {
  console.log(item.toFixed(2)); // ✅ 安全
}

2-4. 関数の引数の個数チェック

// JSでは引数が足りなくても動く
function add(a: number, b: number) { return a + b; }

// TSでは引数の個数チェックがある
add(1);      // ❌ コンパイルエラー
add(1, 2);   // ✅
add(1, 2, 3); // ❌ コンパイルエラー

ただし、関数型として扱う場合は緩くなります:

// コールバック関数の引数は「少なくてもOK」
[1, 2, 3].forEach((item) => {     // ✅ indexを使わなくてもOK
  console.log(item);
});

// これはJSの文化に合わせた設計判断

2-5. Soundnessとのトレードオフ

TypeScriptの公式設計目標にはこう書かれています:

TypeScript does not try to be perfectly sound. Instead, it strikes a balance between correctness and productivity.

「完全に型安全(Sound)」を目指すと、表現できないコードが増え、開発体験が損なわれます。TypeScriptは「現実のJavaScript開発で使える型システム」を優先した設計です。

これを理解した上で any や型アサーションと向き合うと、使うべき場面と避けるべき場面の判断が明確になります。


Chapter 3: 構造的型付け vs 名前的型付け

3-1. 2種類の型システム

名前的型付け(Nominal Typing):JavaやC#

// Java
class Point { int x; int y; }
class Vector { int x; int y; }

void plot(Point p) { ... }

Vector v = new Vector();
plot(v); // ❌ コンパイルエラー
         // Point と Vector は「名前が違う」から別の型
         // implements していないと渡せない

名前的型付けでは、型の名前(またはクラス継承・インターフェース実装の宣言)が互換性の根拠です。

構造的型付け(Structural Typing):TypeScript

interface Point { x: number; y: number }
interface Vector { x: number; y: number }

function plot(p: Point) { ... }

const v: Vector = { x: 1, y: 2 };
plot(v); // ✅ OK
         // 「形が同じ」から互換性がある

構造的型付けでは、型の「形(Shape)」が互換性の根拠です。


3-2. なぜTypeScriptは構造的型付けを選んだのか

もしTypeScriptが名前的型付けを採用していたら、既存のJSライブラリを使うことがほぼ不可能になります。

// React・jQuery・lodash などは互いに implements 宣言をしていない
// 名前的型付けでは全てのライブラリに宣言が必要になる → 現実的に不可能

// 構造的型付けなら「形が合えば動く」
// これはJSのダックタイピング文化と完全に一致する

「アヒルのように歩き、アヒルのように鳴くなら、それはアヒルだ」 ——ダックタイピングの定義


3-3. 構造的型付けの実際の動作

interface Printable { print(): void }

class Document {
  print() { console.log("Document"); }
  save() { /* ... */ }
}

class Image {
  print() { console.log("Image"); }
  resize() { /* ... */ }
}

// Document も Image も Printable を implements していない
// でも「形が合う」から渡せる
function printAll(items: Printable[]) {
  items.forEach(item => item.print());
}

printAll([new Document(), new Image()]); // ✅ 両方OK

3-4. 構造的型付けの「罠」

形が同じなら意味が違っても互換性が生まれます。

interface UserId { value: string }
interface PostId { value: string }

function getUser(id: UserId) { /* ... */ }

const postId: PostId = { value: "post_123" };
getUser(postId); // ✅ 構造が同じだから通る!
                 // 意味的には完全に間違い

これを防ぐには Branded Types(Vol.3で詳説)が必要です。


3-5. 余剰プロパティチェック(Excess Property Check)

構造的型付けには例外があります。オブジェクトリテラルを直接渡す場合は、余剰プロパティがエラーになります。

interface Point { x: number; y: number }

function plot(p: Point) { ... }

// ❌ オブジェクトリテラルの直接渡しは余剰プロパティチェックが働く
plot({ x: 1, y: 2, z: 3 });

// ✅ 変数経由なら構造的型付けの通常ルールが適用される
const p3 = { x: 1, y: 2, z: 3 };
plot(p3); // OK(Point に必要なプロパティを全部持っている)

この非対称な動作は「よくある間違いを防ぐ実用的な妥協」です。


Chapter 4: 型推論の仕組みを完全理解

4-1. 型推論とは何か

TypeScriptは型を書かなくても、コードの文脈から型を「推論」します。

// 全て明示なしで正確に推論される
const name = "Masaki";           // string
const age = 30;                  // number
const arr = [1, "two", true];    // (number | string | boolean)[]
const tuple = [1, "two"] as const; // readonly [1, "two"]

function double(x: number) {
  return x * 2;                  // 戻り値 number を推論
}

4-2. 型の「広がり」(Widening)

letconst で推論される型が異なります。

// const:リテラル型で推論(変更不可なので)
const status = "loading";  // "loading"(string ではない)

// let:一般的な型に「広がる」(変更可能なので)
let status2 = "loading";   // string(後で他の値に変更できるため)

// 意図的にリテラル型に固定したい場合
let status3 = "loading" as const; // "loading"

この「型の広がり」は配列でも起きます:

const arr = ["a", "b", "c"];  // string[]("a" | "b" | "c" ではない)
const tuple = ["a", "b", "c"] as const; // readonly ["a", "b", "c"]

4-3. 型の「絞り込み」(Narrowing)

TypeScriptはコードの分岐を追跡し、型を絞り込みます。

function process(val: string | number | null) {
  // ここでは string | number | null

  if (val === null) {
    return; // ここでは null
  }

  // ここでは string | number(null が除外された)

  if (typeof val === "string") {
    console.log(val.toUpperCase()); // ここでは string
  } else {
    console.log(val.toFixed(2));    // ここでは number
  }
}

4-4. 制御フロー解析(Control Flow Analysis)

TypeScriptはif・switch・while・try/catchなどの制御フローを解析して型を追跡します。

function example(x: string | undefined) {
  if (!x) {
    // x は string | undefined だが falsy なので undefined(または "")
    return;
  }

  // ここでは x は string(undefined が除外された)
  console.log(x.toUpperCase());
}

// アーリーリターン後も型が絞られる
function getLength(val: string | null): number {
  if (val === null) return 0;
  // ここでは string が確定
  return val.length;
}

4-5. 型推論の限界と明示的アノテーションが必要な場所

// ① 関数の公開API(引数の型は推論できない)
function greet(name) { // ← name の型が any になる
  return `Hello, ${name}`;
}

// ② 型が曖昧になる変数の初期化
let items = []; // never[] と推論されてしまう
let items2: string[] = []; // ✅ 明示が必要

// ③ 型推論が意図と異なる場合
const config = {
  timeout: 3000,
  retries: 3,
}; // { timeout: number; retries: number } と推論
   // → timeout を "3000ms" のような文字列に変更したときにエラーになる
   // 意図的に広い型を持たせたいなら明示する

まとめ:Vol.1 のチェックリスト

  • [ ] TypeScriptのコンパイル後に型情報は消えることを理解している
  • [ ] anyunknown の違いを説明できる
  • [ ] strictNullChecks がなぜ重要かを説明できる
  • [ ] 構造的型付けと名前的型付けの違いを説明できる
  • [ ] 余剰プロパティチェックがなぜ「例外的な動作」なのかを理解している
  • [ ] letconst で推論される型が異なる理由を説明できる
  • [ ] 制御フロー解析による Narrowing を使いこなせる

次回 Vol.2「型システム深掘り」 では、Conditional Types・Mapped Types・Template Literal Types・共変/反変を解説します。


質問・フィードバックはコメントにてどうぞ。