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

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は与えられたオブジェクトのfoobarbazを全て足した値を返すという単純な関数です。

この関数は引数でオブジェクトを受け取りますが、そのオブジェクトのプロパティに再代入することはありません。よって、このオブジェクトの型のプロパティを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は変更不可な変数と変更可能な変数がletlet 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.foo0型であるにも関わらず実際には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は「与えられた配列を破壊するかもしれない関数」として宣言されています。変数myArras constで宣言された配列であり、readonlyな配列型を持ちます(より正確には、readonly [1, 1, 2, 3, 5, 8]というタプル型になります)。

myArrsumに渡すのはコンパイルエラーとなります。エラーメッセージを見ると、sumreadonlyでない配列を受け取っているのでreadonlyな配列であるmyArrは渡せないと言っています。

つまり、オブジェクトのときは存在しなかったチェックが配列の場合は存在し、readonly性に対する安全性が保たれているということになります。

逆に言えば、配列を受け取る関数は必要に応じてreadonlyで宣言しておかないと、as constで宣言された(あるいは手動でreadonly配列として宣言された)配列を受け取ることができないということです。特に配列の場合はreadonlyをきちんと引数にアノテートすることは単なる気休め以上の実用的な意味があります。忘れずにつけるようにしましょう。

まとめ

readonlyを使いこなすには、可能なところにできる限りreadonlyを付与します。特に、関数引数が受け取るオブジェクトや配列は、関数内で変更しないならば積極的にreadonlyを付与しましょう。

特に配列の場合はこれをやっておかないとas constを使った際にコンパイルエラーとなってしまう可能性があります。

それ以外でも、readonlyの付加はドキュメントとしての型情報を充実させ、コードのメンテナンス性を向上させるとともに、関数内のコードに対するチェックが受けられるという恩恵があります。

ただし、そのような関数にオブジェクトを渡す際のチェックについてはこの記事で説明したような穴がありますから、過信しないように要注意です。

関連リンク

  • TypeScriptの型入門: readonlyas constに関する網羅的な説明を与えています。
  • TypeScriptの型初級: Readonly<T>型の紹介や、mapped typeとreadonly配列型との関係の解説があります。
  • Readonly - TypeScript Deep Dive 日本語版: この記事と同じテーマを扱っていますが、この記事ではドキュメンテーションとしての意味や危険性という視点を強調しています。
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした