TypeScriptは、大規模な開発においてプログラムの堅牢性を高めるための強力な型システムを提供しています。

その中でも、オブジェクトの状態を不用意に変更させない「不変性(Immutability)」の確保は、バグを未然に防ぐために極めて重要な要素です。

本記事では、TypeScriptでオブジェクトを読み取り専用にするためのreadonly修飾子や関連する機能について、実戦的なアプローチを交えて詳しく解説します。

不変性を正しく扱うことで、コードの予測可能性が向上し、デバッグの効率も飛躍的に改善されるでしょう。

TypeScriptにおける不変性の重要性

現代のフロントエンド開発、特にReactやVue.jsといったフレームワークを用いる環境では、状態管理の複雑さが増しています。

オブジェクトがプログラムのあちこちで勝手に変更されてしまうと、「いつ、どこでデータが書き換わったのか」を特定することが困難になります。

状態の不変性を維持することで、データの流れが一方通行になり、プログラムの挙動を追いやすくなります。

TypeScriptのreadonlyを活用すれば、意図しないプロパティの書き換えをコンパイル時に検知できるようになります。

これは実行時のエラーを防ぐだけでなく、開発者に対して「このオブジェクトは変更してはいけない」という明確な意図を伝えるドキュメントとしての役割も果たします。

readonly修飾子の基本とその仕組み

readonly修飾子は、クラスのプロパティやインターフェースの定義で使用される基本的な機能です。

プロパティ単位での制限

インターフェースや型の定義において、特定のプロパティを読み取り専用にするには、プロパティ名の前にreadonlyを付与します。

TypeScript
// ユーザー情報の型定義
interface User {
  readonly id: number; // 変更不可
  name: string;        // 変更可能
}

const user: User = {
  id: 1,
  name: "田中太郎"
};

// nameは変更できる
user.name = "佐藤次郎";

// idを変更しようとするとコンパイルエラーになる
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property.

このように、特定のキーだけを保護したい場合に非常に有効な手法です。

コンストラクタでの初期化

クラスを使用する場合、readonlyプロパティは宣言時またはコンストラクタ内でのみ初期化が可能です。

TypeScript
class ApiConfig {
  readonly endpoint: string;

  constructor(url: string) {
    this.endpoint = url; // コンストラクタ内での代入は許可される
  }

  updateEndpoint(newUrl: string) {
    // this.endpoint = newUrl; // Error: Cannot assign to 'endpoint' because it is a read-only property.
  }
}

一度インスタンス化された後は、外部からも内部のメソッドからも書き換えができなくなります。

Readonly<T> ユーティリティ型の活用

既存の型をベースに、すべてのプロパティを一括で読み取り専用に変換したい場合には、組み込みのユーティリティ型であるReadonly<T>が便利です。

オブジェクト全体を一括で読み取り専用にする

Readonly<T>を使用すると、各プロパティに手動で修飾子を付ける手間が省けます。

TypeScript
interface Todo {
  title: string;
  description: string;
}

const myTodo: Readonly<Todo> = {
  title: "記事を書く",
  description: "TypeScriptのreadonlyについてまとめる"
};

// myTodo.title = "書き換える"; // Error: Cannot assign to 'title' because it is a read-only property.

この手法は、関数の引数として受け取ったオブジェクトを、関数内で変更されたくない場合に特に役立ちます。

ネストされたオブジェクトの注意点

Readonly<T>には、「浅い(Shallow)制限」しかかからないという重要な性質があります。

オブジェクトのプロパティがさらに別のオブジェクトを持っている場合、その深い階層のプロパティは保護されません。

TypeScript
interface Profile {
  settings: {
    theme: string;
  };
}

const config: Readonly<Profile> = {
  settings: {
    theme: "dark"
  }
};

// settings自体への代入は不可
// config.settings = { theme: "light" }; // Error

// しかし、settingsの中身は変更できてしまう
config.settings.theme = "light"; // 正常に動作してしまう

深い階層まで完全に不変にしたい場合は、再帰的にReadonlyを適用するカスタム型を定義するか、後述するas constを検討する必要があります。

as const(Const Assertions)による厳格な不変性

TypeScript 3.4で導入された「const assertion」を使用すると、オブジェクトや配列をリテラル型として固定し、完全に読み取り専用にすることができます。

リテラル型の固定

as constを付与することで、オブジェクトのすべての階層が自動的にreadonlyになります。

TypeScript
const SYSTEM_STATUS = {
  active: "ACTIVE",
  inactive: "INACTIVE",
  pending: "PENDING"
} as const;

// SYSTEM_STATUS.active = "DISABLED"; // Error

また、プロパティの値も単なるstring型ではなく、具体的な文字列リテラル型として推論されるのが特徴です。

配列への適用

配列に対してas constを使用すると、要素の追加や変更が不可能な「読み取り専用タプル」になります。

TypeScript
const ROLES = ["admin", "editor", "viewer"] as const;

// ROLES.push("guest"); // Error: Property 'push' does not exist on type 'readonly ["admin", "editor", "viewer"]'.
// ROLES[0] = "superadmin"; // Error

定数リストを定義する際には、この手法が最も安全で推奨されるパターンです。

readonlyとObject.freezeの違い

よく混同されがちな機能に、JavaScript標準のObject.freeze()があります。

コンパイル時と実行時の挙動

両者の最大の違いは、「どのタイミングで制限がかかるか」にあります。

機能制限のタイミング実行時の挙動
readonly / Readonly<T>コンパイル時(TypeScript)JavaScriptに変換されると消滅し、実行時は変更可能
Object.freeze()実行時(JavaScript)実行時にエラーを投げたり、変更を無視したりする

readonlyはあくまでTypeScriptの型システム上の制約であり、コンパイル後のJavaScriptでは単なるオブジェクトとして扱われます。

一方、Object.freeze()はJavaScriptエンジンそのものがオブジェクトの変更を禁止します。

多くの場合、開発効率と安全性のバランスを取るためにreadonlyで十分ですが、外部ライブラリに渡すデータなどを物理的に保護したい場合は、両者を併用するのが効果的です。

TypeScript
const config = Object.freeze({
  apiKey: "12345",
  version: 1.0
});

// TypeScriptはObject.freezeを検知して、自動的にReadonly型として推論します
// config.version = 1.1; // Error

実践的なユースケース

実際の開発現場で、不変性をどのように活用すべきか、具体的なシーンを見ていきましょう。

Reactの状態管理における活用

ReactのuseStateなどで管理するオブジェクトは、直接変更(Mutation)してはいけないというルールがあります。

型定義にreadonlyを付与しておくことで、誤ってstate.items.push(newItem)のような破壊的メソッドを使ってしまうミスを防げます。

TypeScript
interface State {
  readonly items: readonly string[];
}

const state: State = {
  items: ["apple", "banana"]
};

// 新しい配列を作成して更新するパターンを強制できる
const newState = {
  ...state,
  items: [...state.items, "cherry"]
};

設定ファイルや定数定義

アプリケーション全体で使用する環境変数や色定義などは、誤って上書きされるとシステム全体に影響を及ぼします。

これらを定義する際は、必ずas constを使用して「完全な定数」として扱うようにしましょう。

TypeScript
export const THEME_COLORS = {
  primary: "#007bff",
  secondary: "#6c757d",
  success: "#28a745"
} as const;

まとめ

TypeScriptにおけるreadonlyオブジェクトの活用は、コードの信頼性を向上させるための第一歩です。

readonlyプロパティ、Readonly<T>ユーティリティ、そしてas constという3つのツールを状況に応じて使い分けることが重要です。

プロパティ単位での制限にはreadonly、型変換にはReadonly<T>、そしてリテラルとしての厳格な保護にはas constが最適です。

不変性を意識したプログラミングは、最初は記述量が増えるように感じるかもしれません。

しかし、長期的なメンテナンス性やチーム開発におけるバグ削減の効果を考えれば、その投資には十分な価値があります。

ぜひ、今日から作成する型定義にreadonlyを取り入れ、より安全なTypeScriptライフを送りましょう。