TypeScriptのreadonlyプロパティを使いこなす

TypeScriptでは、オブジェクト型のプロパティを`readonly`にできる機能があります。型で`readonly`と宣言されているプロパティを書き換えようとするとコンパイルエラーとなります。

```ts
type MyObj = {
  readonly foo: string;
};

const obj: MyObj = {
  foo: "Do not change me!"
};

// これは MyObjのfooプロパティがreadonlyなのでコンパイルエラー
obj.foo = "hi"; 
```

また、これに類似した機能として`readonly`配列もあります。`readonly`配列の要素に再代入することができません。

```ts
const arr: readonly number[] = [0, 1, 2];

// これはarrがreadonly配列なのでコンパイルエラー
arr[2] = 100;
```

この記事では、普段あまり目立たないこの`readonly`という機能をどのように使えばいいのかについて解説します。

## 結論

可能な限りプロパティに`readonly`を付けましょう。**特に、関数が引数としてオブジェクトを受け取る場合は`readonly`の使用を徹底しましょう。**

これは、関数がプロパティを書き換えるかどうかを**型の上にドキュメント化**し、さらに**TypeScriptによるチェックを受けられる**という意味があります。

## 解説

例えばこんな関数を考えます。

```ts
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`を付けましょう。

```ts
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`が変更されないということです。

```ts
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`をわざわざ使うということはその変数をあとから変更するという意思表示である」という考え方と同じです。

```ts
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`と書く必要がなくお手軽です。活用しましょう。

```ts
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`が導入されるのが遅かったため、それ以前のコードとの互換性を考慮して、一部チェックがされない場合があります。チェックがされないのは、具体的には以下のケースです。

```ts
// この関数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などでも述べられているように、これは後方互換性のための意図的な選択です。とはいえ、個人的にはコンパイラオプションでも何でもいいからこの穴を塞ぐ方法が用意されてほしいなあと思います。

- https://github.com/microsoft/TypeScript/issues/13347

### `readonly`配列との関係

ちなみに、`readonly`配列の場合はこのような穴はありません。

```ts
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の型入門](https://qiita.com/uhyo/items/e2fdef2d3236b9bfe74a): `readonly`や`as const`に関する網羅的な説明を与えています。
- [TypeScriptの型初級](https://qiita.com/uhyo/items/da21e2b3c10c8a03952f): `Readonly<T>`型の紹介や、mapped typeと`readonly`配列型との関係の解説があります。
- [Readonly - 
TypeScript Deep Dive 日本語版](https://typescript-jp.gitbook.io/deep-dive/type-system/readonly): この記事と同じテーマを扱っていますが、この記事ではドキュメンテーションとしての意味や危険性という視点を強調しています。