TypeScriptのreadonlyを付けたはずなのに、いつの間にか値が書き換わっていた——そんな経験はありませんか?

オブジェクトのプロパティを書き換え不可にしたいけど、constだけだと中身が変わってしまう…
readonlyを付けたのにネストの奥が書き換えられた。どこまで守ってくれるの?
readonly修飾子・ReadonlyArray・Readonly、結局どれをいつ使えばいいのか分からない…

readonlyは「プロパティや要素の書き換えを型で禁止する」仕組みですが、守れる範囲には明確な境界があります。この記事を読めば、3つの書き方を正しく使い分けられるようになり、「効くと思っていたのに効かなかった」という事故を型レベルで未然に防げるようになります。readonlyは“約束であって鍵ではない”——この一本の軸で、効く所と効かない所を見通せるようになりましょう。

この記事は次のような方におすすめです。

この記事はこんな人におすすめ!
  • クラスやAPIレスポンス、関数引数で「うっかり書き換え」を型で防ぎたい人
  • readonlyを付けたのに値が変わってしまう原因を知りたい人
  • readonly修飾子・ReadonlyArray・Readonly<T>の違いと使い分けを整理したい人
  • 実行時まで含めて値を不変に保つ方法を探している人

読み終えるころには、どの場面でどの書き方を選ぶべきかが判断フローで即決でき、readonlyの限界を踏まえた安全な型設計ができるようになります。

それでは、順を追って詳しく見ていきましょう!

readonlyとは?まず押さえる基本と効果

readonlyは、オブジェクト型のプロパティを「初期化後に再代入できない」状態にする修飾子です。プロパティの前に付けるだけで、そのプロパティへの代入をコンパイラがエラーとして弾いてくれます。

type User = {
  readonly id: number;
  name: string;
};

const user: User = { id: 1, name: "Tanaka" };
user.name = "Sato"; // OK:nameはreadonlyでない
user.id = 2;        // エラー:idはreadonly
// 例:TS2540 Cannot assign to 'id' because it is a read-only property.
//(TypeScriptバージョンにより文言が異なる場合あり)
tsc 実バージョン(例 v5.x)で TS2540 が出力される様子

ここで押さえておきたいのは、readonlyが効くのは「コンパイル時の型チェックのみ」だという点です。型の上では再代入を禁止できますが、JavaScriptに変換されたあとの実行時には何の制約も残りません。あくまで開発中にミスを検知するための“約束”であり、値そのものを物理的にロックする“鍵”ではない、と捉えておくと後半の話がスムーズに理解できます。

readonly修飾子の書き方(オブジェクト型・interface・型エイリアス)

readonlyは型エイリアス(type)でもinterfaceでも、プロパティ名の前に付けるという書き方は同じです。

// 型エイリアス
type Point = {
  readonly x: number;
  readonly y: number;
};

// interface
interface Config {
  readonly endpoint: string;
  readonly timeout: number;
}

インデックスシグネチャに付けると、任意のキーをまとめて読み取り専用にできます。

type ReadonlyDict = {
  readonly [key: string]: number;
};

const scores: ReadonlyDict = { math: 80 };
scores.math = 90; // エラー:read-only property
// 例:TS2542 Index signature in type 'ReadonlyDict' only permits reading.
//(バージョンにより文言が異なる場合あり)

どの書き方でも修飾子の意味は変わらないので、プロジェクトの記法に合わせて選んで問題ありません。

constとの違い(変数の固定 vs プロパティの固定)

constがあるのにreadonlyが必要なのは、両者が固定する対象がまったく別だからです。constは「変数への再束縛」を禁止し、readonlyは「プロパティへの再代入」を禁止します。

const user = { name: "Tanaka" };
user = { name: "Sato" }; // エラー:constなので変数を入れ替えられない
user.name = "Sato";      // OK:プロパティの書き換えは止められない

constはあくまで変数の入れ物を固定するだけで、オブジェクトの中身までは凍結しません。逆にreadonlyはプロパティを守りますが、変数自体の入れ替えには関与しません。

固定する対象 中身(プロパティ)の書き換え
const 変数の再束縛 防げない
readonly プロパティの再代入 防げる

なお、リテラルをまるごと深く固定したい場合はas constという別アプローチがあります。

readonlyが効かない・消える4つの罠(最重要)

readonlyを付けたのに値が書き換えられてしまうのは、型の“約束”には迂回経路がいくつも存在するからです。readonlyは型レベルの契約にすぎないため、その契約が及ばない経路を通られると、いとも簡単に外れてしまいます。代表的な抜け道は次の4つです。

  • 再帰的でない(ネストの奥は守られない)
  • コンパイル時のみで実行時には効かない
  • 可変型への代入や分割代入で外れる
  • 関数引数では保証できる範囲が限られる

それぞれ実際のコードで、どこでreadonlyが抜けるのかを確認していきましょう。

罠1:再帰的でない(ネストは浅くしか守れない)

readonly最上位のプロパティしか守らず、ネストした内側のオブジェクトには効果が及びません。これは“浅い(shallow)”な不変化と呼ばれます。

type Settings = {
  readonly theme: { color: string };
};

const s: Settings = { theme: { color: "dark" } };
s.theme = { color: "light" }; // エラー:themeはreadonly
s.theme.color = "light";      // OK:ネストの奥までは守られない

theme自体の差し替えは防げても、theme.colorの書き換えは通ってしまいます。図にすると、守られるのは外側の一層だけで、内側は素通しというイメージです。

Settings
└─ theme        ← readonly(守られる層)
   └─ color     ← 守られない層(再代入できてしまう)

奥まで再帰的に固定したい場合は、DeepReadonlyのような再帰型を自作するか、as constを使う方法があります。

罠2:コンパイル時のみで実行時は防げない(freezeとの併用)

readonlyは型情報なので、コンパイル後のJavaScriptには一切残りません。型を無視して動く実行時コードや、any経由のアクセスでは、書き換えがそのまま通ってしまいます。

const user = { name: "Tanaka" } as { readonly name: string };
(user as any).name = "Sato"; // 型チェックを迂回 → 実行時は書き換わる

実行時にも本当に値を変えさせたくないなら、Object.freeze()を併用しますObject.freeze()は実行時にオブジェクトを凍結し、変更を無視(strictモードでは例外)させる標準APIです。

const frozen = Object.freeze({ name: "Tanaka" });
(frozen as any).name = "Sato"; // 型チェックを迂回すると、非strictでは無視され、strictではTypeError

なお、Object.freeze()はTypeScript上ではReadonly<T>を返すため、上記のようにas anyで型チェックを迂回しない限り、frozen.nameへの代入はコンパイル時点で型エラーになります。

MDNでは、Object.freeze()はオブジェクトを拡張不可にし、既存プロパティの書き込みや構成を不可能にすると説明されています(新規プロパティの追加・既存プロパティの削除・値の変更ができなくなります)。
出典:MDN「Object.freeze()」

ただしObject.freeze()も浅い凍結である点には注意が必要です。ネストしたオブジェクトまでは凍結しないため、深い不変が必要なら再帰的にfreezeする必要があります。

罠3:可変型への代入・分割代入で外れる

readonlyなプロパティを持つ値を、readonlyでない型の変数に代入すると、その時点でreadonlyの契約が外れます

type RO = { readonly id: number };
type RW = { id: number };

const ro: RO = { id: 1 };
const rw: RW = ro; // 代入できてしまう
rw.id = 2;         // OK:rw経由なら書き換えられる

また、分割代入で取り出した変数は元のreadonlyを引き継ぎません。取り出したあとはただのローカル変数なので、再代入が可能になります。

const config = { theme: "dark" } as { readonly theme: string };
let { theme } = config;
theme = "light"; // OK:themeは独立した変数で、readonlyではない

代入や分割代入で型が「広い(可変な)型」に変わる瞬間が、readonlyの抜け道になると覚えておきましょう。

罠4:関数引数のreadonlyは「実装側の約束」止まり(呼び出し元は守れない)

関数の引数に付けたreadonlyは、「この関数が引数を壊さない」という実装側の約束にすぎません。呼び出し元のデータまで不変にしてくれるわけではない——ここを「渡したデータも守られる」と誤解するのが罠です。

たしかに実装側ではreadonlyが効き、pushspliceといった破壊的操作はコンパイルエラーになります。

function sum(nums: readonly number[]): number {
  nums.push(0); // エラー:readonly配列にpushはできない
  // 例:TS2339 Property 'push' does not exist on type 'readonly number[]'.
  return nums.reduce((a, b) => a + b, 0);
}

しかしこれが守るのは関数の“内側”だけです。呼び出し側は可変な配列をそのまま渡せてしまい、関数を抜けたあとも元データは普通に書き換えられます。

function safe(arr: readonly number[]): void { /* pushなど不可 */ }

const mutable = [1, 2, 3];
safe(mutable);         // OK:可変配列を渡せる
mutable[0] = 99;       // OK:呼び出し後も元データは変更できてしまう

逆に、可変配列を要求する関数へreadonly配列を渡そうとすると、呼び出し側ではじかれます。

function mutate(arr: number[]) { arr.push(1); }

const ro: readonly number[] = [1, 2, 3];
mutate(ro); // エラー:可変を要求する関数に readonly は渡せない

このように、引数のreadonlyが保証するのは「関数の内側で壊さないこと」だけ。「呼び出し元のデータが不変のまま」までは守ってくれない、と切り分けて覚えておきましょう。

配列の不変化:readonly T[] と ReadonlyArray

配列を読み取り専用にするには、readonly number[]ReadonlyArray<number>という2つの書き方があり、両者は等価です。どちらを使っても、pushpopsortspliceといった破壊的メソッドが型から消え、呼び出すとエラーになります。

const nums: readonly number[] = [3, 1, 2];
// もしくは const nums: ReadonlyArray<number> = [3, 1, 2];

nums.push(4); // エラー
// 例:TS2339 Property 'push' does not exist on type 'readonly number[]'.
//(バージョンにより文言が異なる場合あり)

一方で、mapfilterreducesliceなどの非破壊メソッドは問題なく使えます

const doubled = nums.map((n) => n * 2); // OK:新しい配列を返す

タプルにもreadonlyは付けられ、readonly [number, string]のように書けば要素の書き換えを禁止できます。

readonly T[] と ReadonlyArray の使い分け

機能面はまったく同じなので、選ぶ基準は可読性とチーム規約です。readonly number[]は短く書けて素の配列記法に近いため、多くの場面で読みやすくなります。

ただし、ネストした型ではreadonlyプレフィックス記法が読みにくくなることがあります。たとえば二次元配列ではreadonly (readonly number[])[]のように入れ子が深くなり、ReadonlyArray<ReadonlyArray<number>>のほうが構造を追いやすい場合もあります。

推奨としては「基本はreadonly T[]、ネストで読みにくければReadonlyArray<T>という方針を、チームで一つ決めておくと統一できます。なお、配列以外にもReadonlyMapReadonlySetが用意されている点も覚えておくとよいでしょう。

組み込みユーティリティ型 Readonly の使い方

Readonly<T>は、既存の型の全プロパティを一括でreadonlyにするユーティリティ型です。プロパティ一つひとつに修飾子を付ける手間なく、型全体を読み取り専用化できます。

type User = { id: number; name: string };
type ReadonlyUser = Readonly<User>;
// = { readonly id: number; readonly name: string }

const u: ReadonlyUser = { id: 1, name: "Tanaka" };
u.name = "Sato"; // エラー:read-only property
// 例:TS2540(バージョンにより文言が異なる場合あり)

内部的にはMapped Typesで、次のように定義されています。

type Readonly<T> = { readonly [P in keyof T]: T[P]; }
出典:TypeScript 3.4 リリースノート「readonly mapped type modifiers and readonly arrays」

keyof Tで全キーを走査し、それぞれにreadonlyを付けているだけなので、効果は修飾子と同じく“浅い”ものです。ネストした内側までは再帰しない点は、ここでも変わりません。

同じMapped Types系のユーティリティに、全プロパティを省略可能にするPartial<T>があります。「読み取り専用化=Readonly/省略可=Partial」と対で覚えておくと整理しやすいです。

クラスでのreadonly修飾子

クラスのフィールドにreadonlyを付けると、代入できるのは「宣言時」と「constructor内」だけに限定されます。それ以外の場所からの再代入はエラーになります。

class Account {
  readonly id: number;
  public readonly owner: string;

  constructor(id: number, owner: string) {
    this.id = id;       // OK:constructor内なので代入できる
    this.owner = owner; // OK
  }

  rename(name: string) {
    this.owner = name; // エラー:readonlyフィールドは再代入できない
    // 例:TS2540(バージョンにより文言が異なる場合あり)
  }
}

public readonlyのように、アクセス修飾子と組み合わせても問題ありません。さらに、constructorの引数に修飾子を付ける「引数プロパティ(short-hand)」を使うと、フィールド宣言と代入を一度に書けます。

class Point {
  constructor(public readonly x: number, public readonly y: number) {}
}
const p = new Point(1, 2);
p.x = 10; // エラー:readonly

3形態の比較とreadonlyを使うべき場面(判断フロー)

ここまで見てきた3形態にas constを加えて、どれをいつ使うかを一枚の表で整理します。

形態 対象範囲 深さ 配列対応 実行時効果 主な用途
readonly修飾子 個別プロパティ 浅い △(配列プロパティの差し替えのみ。要素保護はreadonly T[] なし クラスの不変フィールド、特定プロパティの保護
ReadonlyArray<T> / readonly T[] 配列・タプル 浅い なし 関数引数、破壊的操作の禁止
Readonly<T> 型の全プロパティ 浅い なし 既存型をまとめて読み取り専用化
as const リテラル全体 リテラル構造は深い(参照先は除く) なし 設定値・定数の固定

選び方は、次のフローで判断すると迷いません。

配列を関数で受け取る?              → ReadonlyArray<T>(readonly T[])
外部参照を含まないリテラルを深く固定? → as const
既存型を丸ごと保護したい?          → Readonly<T>
クラスの特定フィールドを固定?      → readonly 修飾子

推奨の基本方針として、公開APIの配列引数はReadonlyArray<T>を既定にすると、呼び出し側のデータを壊さない安全な関数になります。いずれの形態も実行時の不変は保証しないため、本当に値を凍結したい場面ではObject.freeze()を併用しましょう。

🔴一次情報TODO(筆者記入):readonly 導入前後でのレビュー指摘の変化など、実プロジェクトでの導入効果の実測をここに記入。

よくある質問

Q1:readonlyとconstはどちらを使えばいい?

用途で切り分けます。変数そのものの再束縛を防ぐならconst、オブジェクトのプロパティや配列要素の書き換えを防ぐならreadonlyです。両者は対象が異なるため、constで宣言した変数にreadonlyプロパティを持たせるなど併用も可能です。

Q2:readonlyにしたのに値が書き換えられるのはなぜ?

主な要因は3つで、(1)ネストの奥まで効かない(再帰しない)、(2)コンパイル時のみで実行時は防げない、(3)可変型への代入で契約が外れる、です。本記事の「readonlyが効かない・消える4つの罠」で詳しく扱っています。実行時まで止めたい場合はObject.freeze()の併用が必要です。

Q3:ネストしたオブジェクトを丸ごとreadonlyにしたい

標準のreadonlyは浅いため、奥までは守れませんDeepReadonlyのような再帰型を自作するか、リテラルならas constを使います。ただし実行時のObject.freeze()も浅い点には注意してください。

Q4:readonly T[] と ReadonlyArray の違いは?

機能は等価で、どちらも配列を読み取り専用にします。選ぶ基準は可読性とチーム規約で、短く書けるreadonly T[]が基本、ネストで読みにくい場合はReadonlyArray<T>が向きます。

【付録】さらに学びを深めるためのリソース


さらにTypescriptの学習を進めたい方のために、いくつかのリソースを紹介します。
これらのリソースを活用することで、TypeScriptの型システムについてより深い知識を得ることができるでしょう。

おすすめの書籍

ゼロからわかる TypeScript入門


技術評論社から出版されている「ゼロからわかる TypeScript入門」は、プログラミング初心者や本職プログラマーではない方を主な対象にした入門書です。

変数・条件分岐・ループといった基本から、クラスやインターフェース、モジュールまで段階的に学べる構成になっています。最終章ではWeb APIとJSONを使った非同期Webアプリの作成も体験できるので、「実際に動くものを作る」ところまで到達できます。

プロを目指す人のためのTypeScript入門


技術評論社の「プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで」、通称 ブルーベリー本 です。
JavaScriptの仕様とTypeScript独自の機能を両方押さえつつ、リテラル型・ユニオン型・keyof型・ジェネリクスなど、高度な型表現まで踏み込んで解説しています。TypeScriptの型システムの表現力を本格的に学べる一冊です。

オンラインで参照できる公式ドキュメント

TypeScript公式ハンドブック


https://www.typescriptlang.org/docs/
TypeScriptの公式ドキュメントです。
intersection型を含む、すべての型システムの機能について詳細な説明があります。

TypeScript Deep Dive


https://basarat.gitbook.io/typescript/
TypeScriptの深い部分まで掘り下げて解説しているオンラインブックです。
無料で読むことができ、intersection型についても詳しく説明されています。

TypeScriptの学習は終わりがありません。
新しい機能が常に追加され、より良い書き方が発見されています。
継続的に学習を続けることで、より良いTypeScriptプログラマーになれるはずです。


まとめ – readonlyは型の約束、限界を知って使い分ける

この記事では、TypeScriptのreadonlyで値を不変にできる範囲と、その限界を整理しました。

  • readonlyは“型レベルの約束”であり、再帰せず(浅い)、実行時には効かない
  • 書き方は3形態:個別のreadonly修飾子/配列のReadonlyArray<T>readonly T[]/型全体のReadonly<T>
  • 可変型への代入・分割代入・実行時コードでは契約が外れるため過信しない
  • 実行時まで値を固定したいならObject.freeze()を併用する

readonlyは“鍵”ではなく“約束”だと理解し、効く範囲と限界を踏まえて選べば、「うっかり書き換え」を型で確実に防げるようになります。


※本記事の本文案はAIを活用して作成していますが、記載している内容およびコードは筆者が実際に調査、検証・実行し、内容の正確性を確認した上で公開しています。