readonlyとReadonlyArray<T> / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の10日目です。昨日は『Mapped Typesを活用する』を紹介しました。
 readonly
本日紹介するのはreadonly。このreadonlyには、TypeScriptにおいて2つの文脈が存在することに注意せねばなりません。ひとつは読み取り専用プロパティの文脈でのreadonly、もうひとつは非破壊配列の文脈でのreadonlyです。
これらはTypeScript 3.4で誕生しました。
 readonlyプロパティ
readonlyキーワードは、指定されたプロパティを読み取り専用にすることを示します。
type Obj = {
  a: string;
  readonly b: string;
};
const obj: Obj = {
  a: "A",
  b: "B",
};
obj.a = "AA";
obj.b = "BB"; // Cannot assign to 'b' because it is a read-only property.(2540)
readonlyがついているbプロパティに他の値を代入しようとしても、エラーとなることがわかります。
class の場合の扱い
classの場合は、constructorの引数にreadonlyキーワードを指定することによって、その引数をそのまま同名の読み取り専用プロパティとして扱うことができます。すなわちpublic readonly vのシュガー扱いとなり、短く書くことができます。
class C {
  constructor(readonly v: string) {
    //
  }
}
"use strict";
class C {
  constructor(v) {
    this.v = v;
    //
  }
}
protected, privateの場合はprotected readonly v, private readonly vのように書きます。readonly private vのようにreadonlyを先行させて書くとエラーとなりますので記述順に注意しましょう。
 readonly配列
readonly配列はその配列から破壊メソッドを呼べないようにします。また、その配列を書き換える操作がエラー扱いになります。
const arr1: string[] = ["a", "b", "c"];
const arr2: readonly string[] = ["a", "b", "c"];
arr1.push("d");
arr2.push("d"); // Error
arr1.reverse();
arr2.reverse(); // Error
arr1.sort();
arr2.sort(); // Error
arr1[0] = "AA";
arr2[0] = "AA"; // Error
arr1[3] = "d";
arr2[3] = "d"; // Error
この例で紹介した配列の破壊的操作メソッドについては一例です。筆者はあまり破壊的操作自体を書かないため、該当メソッドすら暗記できていませんが、MDNを参照してください。
型の書き方
string[]の場合はreadonly string[], { a: string }[]の場合はreadonly { a: string }[]と記述します。string[][]のような多次元配列の場合は、readonly (readonly string[])[]のように括弧指定が必要である点に注意してください。
Tupleに対しても指定可能です。[string, number]なTupleであればreadonly [string, number]とします。
Array<T>のようにシュガーを使わずに書きたい場合は、ReadonlyArray<T>を使います。
予約語ではない
やる人はいないと思いますが、次の書き方は許容されてしまう点に注意してください。
type readonly = any;
type T = readonly[];
readonlyが予約語ではないという理由でtype名としてreadonlyが使えるのですが、readonly[]はany[]と解釈されずに「readonlyなlength 0のTuple」として解釈されます。予約語ではないということはつまり、エディタのパースのタイミングによっては一瞬エラーになったり、一瞬補完がうまく働かなかったりしますので、この扱いは頭の片隅に置いておくとよいです。
いつ使う?
readonlyをいつ使うかですが、筆者は常に使っています。ここで過剰と感じる方はおられると思いますが、常に使っています。理由は単純で、ほぼすべての状況で上書きを必要としないからです。上書きしないのにreadonlyをつけないということは「上書きの可能性が残されていることを示唆している」と解釈できるため、少なくとも現段階で上書きしないのであればreadonlyをつけるべきとしています。
ここまでのサンプルコードでは意図的に書かないようにしていたのは、おそらくTypeScript界隈全体でみるとまだまだ書いていない方が多いであろうため、その傾向に合わせて省略していたのですが、筆者の案件では原則としてすべてに指定しています。
この点は賛否両論あると思うのですが、とはいえ5年ちょっと前は「letよりconstは文字数が多くて記述が面倒」と言われていた割に、今ではすっかりconst一色になっているので、まぁそういうことだと思っています。逆にreadonlyに統一してからは「readonlyが付いてないということは上書きされている」という判断をできるようになりました。これは意図的にlet宣言で変数を宣言する状況と似ています。
配列のreadonlyについても同様で、全ての配列に対して指定しています。sort()やreverse()は破壊的操作の中でもしばしば業務上で使用しますが、これらは必ずarr.sort()ではなく[...arr].sort()となるように書きます。
「8文字は長い」という意見はわかるのですがただの慣れだと思うので、read-writeではないプロパティに何もつけないよりはreadonlyをつけておいたほうがよい、という解釈を筆者はしています。TypeScriptが提供する恩恵は最大限受けるのがTypeScriptを言語として採用する価値だと信じているからです。
余談
余談なのでさらっと流しますが、ECMAScriptに将来採用されるかもしれない要素について議論する会議体TC39では興味深い2つのプロポーザルが議論されているので紹介します。
Change Array by copy
ひとつはChange Array by copyというプロポーザルです。記事執筆時点でStage 3です。
.toReversed(), .toSorted()など、配列の破壊的操作を求めていたメソッドと対応する非破壊のメソッドが提案されています。Stage 3ということで採用の可能性が高まっており、実際に業務で使える日がくることを楽しみにしています。
Records & Tuples
もうひとつ、興味深いプロポーザルが提出されています。Records & Tuplesは記事執筆時点でStage 2です。
以下、サンプルコードを引用します。
const ship1 = #{ x: 1, y: 2 };
const tuple = #[1, 2, 3];
このように#{}, #[]という表記をすることでRecordリテラルとTupleリテラルを書けるようにしたいというものです。最大の特徴はimmutable(不変)かつ比較可能ということ。
assert({ a: 1 } === { a: 1 }); // Fail
assert([1] === [1]); // Fail
assert(#{ a: 1 } === #{ a: 1 }); // Pass
assert(#[1] === #[1]); // Pass
これは楽しみですね。TC39のStage 2プロポーザルについてはまだ話半分というところですが、時代の流れとして非破壊、不変が求められているという傾向を示すいい例だと思っています。
 明日は『Mapping Modifiersと実例 Writable<T, K>』
readonlyと書くのは面倒、それは慣れです!ぜひ一度readonlyの活用を検討してみてもらいたいと思います。明日はreadonlyと組み合わせて使える自作Utility TypeWritable<T, K>について、その定義と用途について紹介します。それではまた。
Discussion