←記事一覧

型システム詳解 — 静的型付けは何を守ってくれるのか

静的型付けと動的型付け、構造的型付けと公称型付け、型推論、ジェネリクス、null 安全性を、TypeScript・Go・Rust・Java・Python の比較とインタラクティブな図で説明します。

Type SystemTypeScriptGoRustInteractive

型システムは、プログラミング言語を選ぶときに重視される特性のひとつです。ただ、言語ごとの違いは「静的型付けか動的型付けか」だけでは表しきれません。型の互換性を構造と名前のどちらで判定するか、型をどこまで推論するか、ジェネリクスをどう実現するか、null をどう扱うかでも違いが出ます。TypeScript・Go・Rust・Java・Python は、この 4 つの点でそれぞれ異なる選択をしています。

型システムとは何か

型システムとは、プログラム中の値を分類し、不正な操作を防ぐルールの体系です。例えば、文字列を割り算する、存在しないメソッドを呼び出す、といったバグを実行前または実行時に検出します。

Benjamin Pierce は著書『Types and Programming Languages』(通称 TaPL)で、型システムを次のように定義しています:

A type system is a tractable syntactic method for proving the absence of certain program behaviors by classifying phrases according to the kinds of values they compute.

日本語にすると、「プログラムの各部分を、それが計算する値の種類によって分類し、特定の振る舞いが起こらないことを証明する、扱いやすい構文的手法」です。

静的型付け vs 動的型付け

型システムの分類型システム静的型付けコンパイル時にチェックJava, Go, Rust, C++動的型付け実行時にチェックPython, Ruby, JavaScript漸進的型付け静的と動的の混合TypeScript, Python+mypy

静的型付け

静的型付けでは、型チェックをコンパイル時に行うので、プログラムを実行する前に型エラーが見つかります。

// Go: コンパイル時に型エラーが検出される
func add(a int, b int) int {
    return a + b
}
 
func main() {
    // add("hello", "world")  // コンパイルエラー: cannot use "hello" (string) as int
    result := add(1, 2)       // OK
    fmt.Println(result)
}

静的型付けには、次の利点があります。

  • バグを早く見つけられる(CI でコンパイルするだけで型エラーを検出)
  • IDE が型情報を使って補完やリファクタリングを支援できる
  • 型注釈を読めば、関数が何を受け取り何を返すかがわかる

代表的な言語は Go・Rust・Java・C++・Haskell です。

動的型付け

動的型付けでは、型チェックを実行時に行います。変数には型がなく、値が型を持ちます。

# Python: 実行時に型エラーが発生する
def add(a, b):
    return a + b
 
print(add(1, 2))         # 3 — OK
print(add("hello", "!")) # "hello!" — 文字列の連結として動作
# print(add(1, "hello"))  # TypeError: unsupported operand type(s) at runtime

動的型付けには、次の利点があります。

  • 型注釈を書かないぶん記述が短く、プロトタイプを素早く作れる
  • 必要なメソッドさえあれば型を問わず渡せる(ダックタイピング)
  • 型の概念を学ぶ前から書き始められる

代表的な言語は Python・Ruby・JavaScript・PHP です。

漸進的型付け(Gradual Typing)

漸進的型付けは、静的型付けと動的型付けを 1 つの言語で併用する方式です。型注釈を少しずつ追加でき、注釈のない部分は動的型付けとして振る舞います。

// TypeScript: 漸進的に型を追加できる
function greet(name: string): string {
  return `Hello, ${name}!`;
}
 
// any を使えば動的型付けのように書ける(非推奨)
function legacy(x: any) {
  return x.foo.bar; // 型チェックされない
}

TypeScript は JavaScript のスーパーセットとして設計されているので、既存の JavaScript コードベースに少しずつ型を導入できます。Python でも PEP 484 で型ヒントが導入され、mypy などの外部ツールで静的にチェックできます。

構造的型付け vs 公称型付け

静的型付けの言語でも、型の互換性の判定方法には 2 つの方式があります。

構造的型付け vs 公称型付け構造的型付けTypeScript, Go (interface)Dogname: stringage: numberCatname: stringage: number✓ 互換 (同じ構造)Dog = Cat が許される// どちらも {name, age} を持つlet x: Dog = cat; // OK ✓公称型付けJava, C#, Rust, KotlinDogname: Stringage: intCatname: Stringage: int✗ 非互換 (異なる型名)フィールドが同じでも Dog ≠ Cat// 同じフィールド、別の型Dog x = cat; // Error ✗

構造的型付け(Structural Typing)

型の互換性を構造(shape)で判定します。型の名前に関係なく、必要なプロパティーやメソッドを持っていれば互換になります。

// TypeScript: 構造的型付け
interface Printable {
  toString(): string;
}
 
class Dog {
  constructor(public name: string) {}
  toString() { return `Dog: ${this.name}`; }
}
 
// Dog は Printable を明示的に implements していないが、
// toString() を持っているので Printable と互換
function print(p: Printable) {
  console.log(p.toString());
}
 
print(new Dog("Pochi")); // OK — 構造が一致するため

Go の interface も構造的型付けです:

// Go: interface は暗黙的に実装される(構造的型付け)
type Stringer interface {
    String() string
}
 
type Dog struct {
    Name string
}
 
// Dog は Stringer を明示的に宣言していないが、
// String() メソッドを持つので Stringer を満たす
func (d Dog) String() string {
    return "Dog: " + d.Name
}
 
func Print(s Stringer) {
    fmt.Println(s.String())
}

公称型付け(Nominal Typing)

型の互換性を型の名前(宣言)で判定します。構造が同一でも、異なる型として宣言されていれば互換ではありません。

// Java: 公称型付け
class Dog {
    String name;
    int age;
}
 
class Cat {
    String name;
    int age;
}
 
// Dog と Cat は同じフィールドを持つが、互換ではない
Dog dog = new Dog();
// Cat cat = dog; // コンパイルエラー: incompatible types

Rust も公称型付けです。同じフィールドを持つ struct の間でも、明示的な変換が必要です。

struct Meters(f64);
struct Kilometers(f64);
 
// 同じ f64 のラッパーだが、型としては別物
let m = Meters(1000.0);
// let km: Kilometers = m; // コンパイルエラー: mismatched types

この例のように既存の型を別の名前の型で包むパターンを、newtype パターンと呼びます。中身が同じ f64 でも Meters と Kilometers を取り違えるとコンパイルエラーになるので、距離・通貨・ID など意味の異なる値を型で区別できます。

どちらが優れているか?

優劣は一概に決まらず、向いている場面が異なります。次の表の「高い」「低い」は、2 つの方式を比べた相対的な評価です。柔軟性は宣言なしで型を互換にできるか、安全性は意図しない互換をコンパイラーが防げるかで判断しています。

観点構造的型付け公称型付け
柔軟性高い(事後的な互換性)低い(明示的な宣言が必要)
安全性やや低い(意図しない互換)高い(明示的な型の区別)
適した場面API 境界、外部データの扱いドメインモデリング、意味の異なる値の区別

型推論

型推論は、型注釈を書かなくても、コンパイラーが式から型を決める機能です。

型推論のステップ

TypeScript が型注釈のない式から型を推論する過程を、1 ステップずつ確認できます。

let x = 42;
推論結果x: number

リテラル 42 から x の型を number と推論

型環境
x: number
1 / 7

各言語の型推論の能力

どこまで推論できるかは、言語によって大きく異なります。

TypeScript は、オブジェクトの配列やコールバックの戻り値の型まで推論します。

// 型注釈なしで複雑な型も推論される
const users = [
  { name: "Alice", age: 30 },
  { name: "Bob", age: 25 },
];
// users: { name: string; age: number }[] と推論
 
const oldest = users.reduce((prev, curr) =>
  prev.age > curr.age ? prev : curr
);
// oldest: { name: string; age: number } と推論

Rust は、変数が後でどう使われるかから型を決めます。

// 使われ方から型が決まる
let mut v = Vec::new(); // この時点では Vec<_>(型未確定)
v.push(1);              // ここで Vec<i32> と確定
 
// クロージャの引数型も推論される
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();

Rust の型推論は、Hindley-Milner 型推論の考え方に基づく、ローカルな制約ベースの推論です。グローバルな Hindley-Milner とは異なり、トレイト、ライフタイム、型強制、メソッド解決といった Rust 固有の制約も加わります。関数シグネチャには型注釈を必須にしているので、API が読みやすく、推論の結果も予測しやすくなっています。

Go は、主に := で宣言した変数の型を推論します。

// := による型推論
x := 42          // int と推論
s := "hello"     // string と推論
m := map[string]int{"a": 1} // map[string]int と推論
 
// ただし関数引数や戻り値には型注釈が必須
func add(a, b int) int { // <-- 推論されない
    return a + b
}

Go は型推論を意図的に限定しています。Go の FAQ は、ジェネリクスの型引数を推論できない場合がある理由を「どの型が推論されたかについて混乱が生じないよう、型推論は意図的に限定されている。他の言語での経験から、予期しない型推論はプログラムを読むときやデバッグするときに大きな混乱を招きうる」と説明しています。

ジェネリクス(パラメーター多相)

ジェネリクスは、型をパラメーターとして抽象化する仕組みです。同じロジックを異なる型に対して再利用できます。

TypeScript のジェネリクス

// T は型パラメータ
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
 
const num = first([1, 2, 3]);     // num: number | undefined
const str = first(["a", "b"]);    // str: string | undefined

Go のジェネリクス(1.18 以降)

// Go 1.18 で導入された型パラメータ
func First[T any](s []T) (T, bool) {
    if len(s) == 0 {
        var zero T
        return zero, false
    }
    return s[0], true
}
 
// 型制約でメソッドや演算子を要求できる
type Number interface {
    ~int | ~float64
}
 
func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

Go のジェネリクスは 1.18 で導入され、型制約(type constraints)を interface で表します。~int のようなチルダ構文は、基底型(underlying type)が int であるすべての型を許容します。

導入当初の Go には「メソッドは自分の型パラメーターを宣言できない」という制約があり、型に紐づく汎用処理もパッケージレベルの関数として書く必要がありました。この制約は Go 1.27 で緩和され、メソッド宣言が自分の型パラメーターを持てるようになりました(例: math/rand/v2 の (*Rand) N[Int intType](Int) Int)。ただし interface のメソッドは依然として型パラメーターを宣言できず、ジェネリックメソッドで interface を実装することもできません。

Rust のジェネリクス + トレイト境界

// トレイト境界で型パラメータに要件を課す
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in &list[1..] {
        if item > largest {
            largest = item;
        }
    }
    largest
}

Rust のジェネリクスは単相化(monomorphization)によってコンパイル時に具体的な型ごとに展開されるので、実行時のオーバーヘッドがありません。一方、Java のジェネリクスは型消去(type erasure)を使うので、実行時には型パラメーターの情報が残りません。

Java の型消去

// コンパイル後、T は Object に消去される
public class Box<T> {
    private T value;
    public T getValue() { return value; }
}
 
// 実行時には Box<String> も Box<Integer> も同じ Box クラス
Box<String> stringBox = new Box<>();
Box<Integer> intBox = new Box<>();
System.out.println(stringBox.getClass() == intBox.getClass()); // true

型消去は、Java 5 でジェネリクスを追加する際に後方互換性を保つための設計判断でした。既存のバイトコードと互換のままジェネリクスを導入できた代わりに、実行時には型情報を参照できなくなりました。

null 安全性

多くの言語で、null 参照は深刻なバグの原因です。Tony Hoare は null 参照の発明を「10 億ドルの過ち(billion dollar mistake)」と呼びました。この問題への対処の仕方は、言語によって異なります。

Rust: Option<T> — 値がないことを型で表す

// Rust には null がない — Option<T> を使う
fn find_user(id: u64) -> Option<String> {
    if id == 1 {
        Some("Alice".to_string())
    } else {
        None
    }
}
 
// パターンマッチで安全に値を取り出す
match find_user(1) {
    Some(name) => println!("Found: {}", name),
    None => println!("Not found"),
}

Rust の安全な参照は null になりません。値がないかもしれないことは、Option<T> 型で表します。Option は Some(T) と None のいずれかをとる enum で、中の値を使うには、パターンマッチや if let、? 演算子などで明示的に取り出す必要があります。raw pointer や FFI では null を表現できますが、安全に扱うには明示的な unsafe 境界やチェックが必要です。

TypeScript: strictNullChecks — null を型の一部として扱う

// strictNullChecks: true の場合
function getLength(s: string | null): number {
  // return s.length; // エラー: 's' is possibly 'null'
  if (s !== null) {
    return s.length; // OK — null チェック後は string として扱える
  }
  return 0;
}

TypeScript では、strictNullChecks コンパイラーオプションを有効にすると、null と undefined をユニオン型の一部として明示的に扱います。制御フロー分析(control flow analysis)によって、条件分岐の後では型が絞り込まれます(type narrowing)。

Go: nil — コンパイル時には nil を検査しない

// Go はコンパイル時に nil を追跡しない
func findUser(id int) *User {
    if id == 1 {
        return &User{Name: "Alice"}
    }
    return nil // nil が返る可能性がある
}
 
// nil チェックを忘れるとランタイムパニック
user := findUser(2)
// fmt.Println(user.Name) // panic: nil pointer dereference
if user != nil {
    fmt.Println(user.Name) // 安全
}

Go のポインター型には nil があり、nil チェックを忘れるとランタイムパニックが発生します。Go の型は nil になりうるかどうかを区別せず、コンパイラーも追跡しないので、チェック漏れは実行時まで見つかりません。シンプルさを優先した、Go の設計上のトレードオフです。

各言語の型システム比較

型システムの比較言語型付け構造的?型推論ジェネリクスnull 安全性TypeScript静的 (漸進的)✓広範✓strictNullChecksGo静的✓限定的 (:=)✓ (1.18〜)なし (nil あり)Rust静的✗広範✓Option<T>Java静的✗限定的 (var)✓ (型消去)Optional (部分的)Python動的 (+ ヒント)✗—✓ (ヒント)Optional (ヒント)

まとめ

  • 静的型付けはコンパイル時に、動的型付けは実行時に型を検査する。漸進的型付けは両者を段階的に混ぜる
  • 構造的型付けは型の構造(shape)で、公称型付けは型の名前(宣言)で互換性を判定する
  • 型推論の能力は言語によって大きく異なる。Rust や TypeScript は注釈なしでも多くの型を推論でき、Go は読みやすさのために意図的に制限している
  • ジェネリクスは型をパラメーター化する仕組みで、設計は言語ごとに異なる。Rust は単相化で、Java は型消去で実現し、Go は型制約を interface で表す
  • null 安全性では、Rust の Option<T> が安全な参照について特に厳密で、TypeScript は strictNullChecks を有効にすると null を型の一部として扱い、Go は nil をコンパイル時には検査しない

どの言語の型システムも、設計者が想定する用途に合わせて選んだトレードオフの組み合わせで、「最強の型システム」はありません。自分が使う言語の型システムが何を保証し、何を保証しないかを知っておけば、どこにテストやチェックを足すべきかが見えてきます。

参考文献