TypeScriptでは、オブジェクト型のプロパティをreadonly
にできる機能があります。型でreadonly
と宣言されているプロパティを書き換えようとするとコンパイルエラーとなります。
type MyObj = {
readonly foo: string;
};
const obj: MyObj = {
foo: "Do not change me!"
};
// これは MyObjのfooプロパティがreadonlyなのでコンパイルエラー
obj.foo = "hi";
また、これに類似した機能としてreadonly
配列もあります。readonly
配列の要素に再代入することができません。
const arr: readonly number[] = [0, 1, 2];
// これはarrがreadonly配列なのでコンパイルエラー
arr[2] = 100;
この記事では、普段あまり目立たないこのreadonly
という機能をどのように使えばいいのかについて解説します。
結論
可能な限りプロパティにreadonly
を付けましょう。特に、関数が引数としてオブジェクトを受け取る場合はreadonly
の使用を徹底しましょう。
これは、関数がプロパティを書き換えるかどうかを型の上にドキュメント化し、さらにTypeScriptによるチェックを受けられるという意味があります。
解説
例えばこんな関数を考えます。
type FooBarBaz = {
foo: number;
bar: number;
baz: number;
}
function sum(obj: FooBarBaz) {
return obj.foo + obj.bar + obj.baz;
}
この関数sum
は与えられたオブジェクトのfoo
、bar
、baz
を全て足した値を返すという単純な関数です。
この関数は引数でオブジェクトを受け取りますが、そのオブジェクトのプロパティに再代入することはありません。よって、このオブジェクトの型のプロパティをreadonly
にしても問題なく動作します。なので、この場合はreadonly
を付けましょう。
type FooBarBaz = {
readonly foo: number;
readonly bar: number;
readonly baz: number;
}
function sum(obj: FooBarBaz) {
return obj.foo + obj.bar + obj.baz;
}
関数引数のオブジェクト型がreadonly
プロパティを持っていることは、渡されたオブジェクトがその関数内では変更されないことを意味します。この例の場合、使う側から見るとsum(obj)
を呼び出してもobj
が変更されないということです。
const obj = {
foo: 0,
bar: 100,
baz: -50,
};
sum(obj);
// objはsumによって変更されないのでobj.barは100のままであることは明らか!
console.log(obj.bar);
逆に言えば、渡されたオブジェクトを変更しない関数の場合は引数のオブジェクトの型に積極的にreadonly
を付けるべきだということです。これにより、その関数を使う人は、関数の中身を見なくても型情報を見るだけで「この関数は渡されたオブジェクトを破壊しない」という安心感を(TypeScriptコンパイラによる保証付きで)得ることができます。
さらに言えば、readonly
という道具が存在するにも関わらずreadonly
が付いていないということは、そのプロパティを勝手に変更するかもしれないよという意思表示だということです。これは「変数宣言にconst
ではなくlet
をわざわざ使うということはその変数をあとから変更するという意思表示である」という考え方と同じです。
function sum(obj: { foo: number; bar: number; baz: number}) {
// この関数はobjのプロパティにreadonlyが付いていないのでobjを変更できるぞ!
obj.foo = 99999999999;
return obj.foo + obj.bar + obj.baz;
}
ですから、引数のオブジェクト型にreadonly
を付けない場合は関数の意味を誤解され、渡されたオブジェクトを破壊する関数であると誤認される可能性があります。それを防ぐためにもreadonly
をどんどん付けましょう。
ひとつ残念なのは、普通の状態を示すのにわざわざreadonly
という長い単語を余計に書かなければいけないことです。今の御時世では渡されたオブジェクトをわざわざ変更する関数のほうが少ないのですから、今からTypeScriptをリデザインするならデフォルトをreadonly
にして変更可能なプロパティをwritable
みたいな感じにするほうが賢明です。余談ですが、Rustは変更不可な変数と変更可能な変数がlet
とlet mut
なので上手ですね。
この問題を低減させる方法のひとつとして、Readonly<T>
組み込み型を用いる方法があります。これは、T
の全てのプロパティにreadonly
を付加して得られる型です。これなら型定義の際に全てのプロパティにreadonly
と書く必要がなくお手軽です。活用しましょう。
type FooBarBaz = {
foo: number;
bar: number;
baz: number;
}
function sum(obj: Readonly<FooBarBaz>) {
obj.foo = 999; // これはコンパイルエラー
return obj.foo + obj.bar + obj.baz;
}
Readonly
はネストしたオブジェクトに効果がないのがネックです。ネストしたオブジェクトも全部readonly
化するようなDeepReadonly<T>
を定義することは可能ですが、標準ライブラリには含まれていません。必要ならば既存のものを利用するか自作しましょう。
型システムとの関係・注意点
実は、TypeScriptによるreadonly
のサポートは完璧ではありません。TypeScriptにreadonly
が導入されるのが遅かったため、それ以前のコードとの互換性を考慮して、一部チェックがされない場合があります。チェックがされないのは、具体的には以下のケースです。
// この関数sumは引数のオブジェクト型にreadonlyが付いていないので、
// 「渡されたオブジェクトを勝手に変更するかもしれない関数」として宣言されている
function sum(obj: { foo: number; bar: number; baz: number}) {
obj.foo = 9999999;
return obj.foo + obj.bar + obj.baz;
}
// このオブジェクトはas constが指定されているので変更不可である
const myObj = {
foo: 0,
bar: 100,
baz: 10000
} as const;
// myObjが変更されるかもしれないのにコンパイルエラーとならない!
sum(myObj);
// これはコンパイルエラーとなる
// (TypeScriptはmyObj.fooは0であると思っているため)
console.log(myObj.foo === 9999999);
この例では関数sum
が「渡されたオブジェクトを勝手に変更するかもしれない関数」として宣言されています(そして実際変更します)。
一方、変数myObj
はTypeScript 3.4で導入されたas const
付きのオブジェクトであり、変更不可なものとして宣言されています。言い方を変えれば、myObj
は{ readonly foo: 0; readonly bar: 100; readonly baz: 10000 }
型を持っています。 foo
などがnumber
ではなく0
型なのは、readonly
なので最初に入っていた0
という値から変わることはないだろうという判断です。
本来であれば、sum(myObj);
はコンパイルエラーとなるべきです。なぜなら、myObj
はプロパティにreadonly
型がついている「プロパティを変えてはいけないオブジェクト」であり、sum
は「プロパティを変えるかもしれない関数」なので、sum
によってmyObj
のプロパティが変えられてしまう可能性があるからです。
しかし実際はコンパイルエラーとはならず、myObj
は無残にもsum
によって破壊されてしまいます。その結果、myObj.foo
は0
型であるにも関わらず実際には9999999
が入っているという危険な状況が発生してしまいました。
このように、本来はreadonly
なプロパティを持つオブジェクトをreadonly
ではないオブジェクトを受け取る関数に渡してはいけないはずですが、TypeScriptコンパイラは現状ではそのチェックを欠いています。以下のissueなどでも述べられているように、これは後方互換性のための意図的な選択です。とはいえ、個人的にはコンパイラオプションでも何でもいいからこの穴を塞ぐ方法が用意されてほしいなあと思います。
readonly
配列との関係
ちなみに、readonly
配列の場合はこのような穴はありません。
function sum(arr: number[]) {
return arr.reduce((a, b) => a + b, 0);
}
const myArr = [1, 1, 2, 3, 5, 8] as const;
// コンパイルエラーが発生
// Argument of type 'readonly [1, 1, 2, 3, 5, 8]' is not assignable to parameter of type 'number[]'.
// The type 'readonly [1, 1, 2, 3, 5, 8]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
sum(myArr);
この例でもやはりsum
は「与えられた配列を破壊するかもしれない関数」として宣言されています。変数myArr
はas const
で宣言された配列であり、readonly
な配列型を持ちます(より正確には、readonly [1, 1, 2, 3, 5, 8]
というタプル型になります)。
myArr
をsum
に渡すのはコンパイルエラーとなります。エラーメッセージを見ると、sum
はreadonly
でない配列を受け取っているのでreadonly
な配列であるmyArr
は渡せないと言っています。
つまり、オブジェクトのときは存在しなかったチェックが配列の場合は存在し、readonly
性に対する安全性が保たれているということになります。
逆に言えば、配列を受け取る関数は必要に応じてreadonly
で宣言しておかないと、as const
で宣言された(あるいは手動でreadonly
配列として宣言された)配列を受け取ることができないということです。特に配列の場合はreadonly
をきちんと引数にアノテートすることは単なる気休め以上の実用的な意味があります。忘れずにつけるようにしましょう。
まとめ
readonly
を使いこなすには、可能なところにできる限りreadonly
を付与します。特に、関数引数が受け取るオブジェクトや配列は、関数内で変更しないならば積極的にreadonly
を付与しましょう。
特に配列の場合はこれをやっておかないとas const
を使った際にコンパイルエラーとなってしまう可能性があります。
それ以外でも、readonly
の付加はドキュメントとしての型情報を充実させ、コードのメンテナンス性を向上させるとともに、関数内のコードに対するチェックが受けられるという恩恵があります。
ただし、そのような関数にオブジェクトを渡す際のチェックについてはこの記事で説明したような穴がありますから、過信しないように要注意です。
関連リンク
-
TypeScriptの型入門:
readonly
やas const
に関する網羅的な説明を与えています。 -
TypeScriptの型初級:
Readonly<T>
型の紹介や、mapped typeとreadonly
配列型との関係の解説があります。 -
Readonly -
TypeScript Deep Dive 日本語版: この記事と同じテーマを扱っていますが、この記事ではドキュメンテーションとしての意味や危険性という視点を強調しています。