TypeScript をより安全に使うために その 1: オブジェクトの mutable な操作を避ける

ふだん TypeScript のコードレビュー時に参考に貼ったりしている内部ドキュメントがあるのですが, 内部では何かと人目につきにくいので, 内容を整えて公開していきます.


TypeScript の型システムは安全ではありません. つまり型検査を通過したコードであっても, 実行時にエラーが発生する可能性があります. TypeScript の設計についてのドキュメントにおいても, non-goals の一つとして

Apply a sound or "provably correct" type system. Instead, strike a balance between correctness and productivity.

が挙げられており, 完全に安全なものを目指すのではなく, 安全性と利便性とのバランスをとるものとされています.

かといって TypeScript を使うならば安全性は諦めざるを得ないというわけではありません. 型システムで安全性が保証できないケースにはいくつかパターンがあり, そういったパターンを把握して避けることができていれば, 十分に堅牢なコードを書くことも可能です.

これは他の言語では例えば null pointer を避けて option 型を使うといったことに対応しますが, 一方で TypeScript の場合はより入り組んだ状況になっていることも多く, あまり広くは知られていないのが実情だと思います.

この記事では, そういった TypeScript における「危険な」パターンにはどのようなものがあり, それらを避けてより安全に使うためにはどうしたらよいかを紹介します.

環境は以下を前提とします:

  • TypeScript 4.4 (この記事を書いている 2021 年 10 月時点の最新版)
  • strict: true

原則: オブジェクトの mutable な操作を避ける

現代の JavaScript / TypeScript を使った開発では, オブジェクトを immutable なものとして扱うプログラミングパラダイムがごく一般的なものになっています. その上で TypeScript の設計においては, オブジェクトを mutable に扱った場合の安全性と, immutable に扱った場合の利便性が衝突した場合, 後者を優先するような設計が採用されてきました.

このため, より安全なコードを書くためには, 基本的にはオブジェクトを immutable なものとして扱い, mutable な操作は避けるのが原則となります.

具体例

ここでは具体的に, どのような場合に mutable な操作が安全でないのか, また逆にどういった場合であれば mutable な操作が許容できるのかについて, 2 つ例を紹介します.

オブジェクト型はプロパティについて covariant である

TypeScript において, 型 TU の部分型であった場合, オブジェクト型 { key: T }{ key: U } の部分型として扱われます.

例えば以下の場合, numbernumber | string の部分型なので XY の部分型であり, したがって X 型の値 xY 型にアップキャストすることが可能です.

type X = { foo: number; bar: boolean };
type Y = { foo: number | string; bar: boolean };

const x: X = { foo: 42, bar: true };
const y: Y = x; // OK

オブジェクトを immutable に扱っている限りは, このようなアップキャストをしても, プロパティの型と値の間に矛盾が生じることはありません. したがって利便性の観点からは, アップキャストを許容するのは妥当と言えます.

const xFoo: number = x.foo;          // = 42 (矛盾なし)
const yFoo: number | string = y.foo; // = 42 (矛盾なし)

ところがオブジェクトのプロパティに対して mutable な変更を許した場合, このアップキャストは安全ではなくなります.

ここでは y.foonumber | string 型であるため, string 型の値も代入することができてしまいますが, すると同時に x.foo の値も string 型の値に書き換わることになります. しかし x.foo の型は number ということになっているため, プロパティの型と値が矛盾してしまいます.

y.foo = "BOOM";

const xFoo: number = x.foo;          // = "BOOM" (矛盾)
const yFoo: number | string = y.foo; // = "BOOM" (矛盾なし)

こういった問題を避けるため, プロパティを変更したオブジェクトが必要な場合は, 原則として mutable に変更するのではなく immutable に複製するようにしましょう.

const y: Y = { ...x, foo: "BOOM" }; // = { foo: "BOOM", bar: true }

よく見るアンチパターンとしては, 関数の引数のプロパティを mutable に書き換えるようなものです. この場合も引数を渡す時点でアップキャストが行われている可能性があるため, やはり mutable な変更は避けるようにしましょう.

// Bad
function setFoo(arg: Y): Y {
  arg.foo = "BOOM";
  return arg;
}
const y = setFoo(x);

// Good
function setFoo(arg: Y): Y {
  return { ...arg, foo: "BOOM" };
}
const y = setFoo(x);

誤って mutable な書き換えを行わないようにするために, readonly 修飾子を利用しておくのも手です. プロパティを { readonly key: T } のように定義する, あるいは Readonly<T> を使ってオブジェクト型を変換して readonly 修飾子を付与しておくことで, プロパティの書き換え自体がコンパイル時にエラーとして報告されるようになります.

type X = Readonly<{ foo: number; bar: boolean }>;
type Y = Readonly<{ foo: number | string; bar: boolean }>;

const x: X = { foo: 42, bar: true };
const y: Y = x;

y.foo = "BOOM"; // エラー

ただし readonly 修飾子がついていても, readonly 修飾子のない型へのキャストはなぜか可能なので注意しましょう.

type X = Readonly<{ foo: number; bar: boolean }>;
type Y = { foo: number | string; bar: boolean };

const x: X = { foo: 42, bar: true };
const y: Y = x; // OK

y.foo = "BOOM"; // OK

プロパティを mutable に書き換えることを許容できる例外として, オブジェクト型がプロパティについて covariant であることを利用していないことが保証できる場合があります. 例えばローカル変数を扱っていて, 上記の例のようなアップキャストが行われていないことが明らかであれば, mutable な変更を行っていても (安全性の観点からは) 問題ありません.

メソッドは引数について bivariant である

まず TypeScript において, オブジェクト型のメソッドとプロパティはそれぞれ以下のように書くことができます.

type Obj = {
  property: (arg: number) => void; // プロパティ
  method(arg: number): void;       // メソッド
};

これらは単に記法の違いではなく, 意味の上でも異なっています.

プロパティの場合 (あるいは通常の関数の場合), 関数の引数の型について contravariant なものとして扱われるため, 以下のような y1 への代入はエラーになります. 実際 y1.foo("BOOM") のように関数を呼び出されては困るので, これは妥当と言えるでしょう.

type X1 = { foo: (arg: number) => number };
type Y1 = { foo: (arg: number | string) => number };

const x1: X1 = { foo: (arg) => arg + 1 };
const y1: Y1 = x1; // エラー

一方でメソッドの場合, 引数の型について bivariant なものとして扱われるため, 以下の y2 への代入はエラーになりません. 困りましたね.

type X2 = { foo(arg: number): number };
type Y2 = { foo(arg: number | string): number };

const x2: X2 = { foo: (arg) => arg + 1 };
const y2: Y2 = x2; // OK

なぜ TypeScript の設計がこのようになっているかというと, 配列のようなオブジェクトの存在が大きいです. 例えば以下のようなコードは許容されるべきだと考える人が多いのではないでしょうか.

const xs: number[] = [1, 2, 3];
const ys: (number | string)[] = xs; // OK

実際には配列には push() のような要素を変更するメソッドがあるため, 以下のようにして安全性を破壊することができてしまいます.

ys.push("BOOM");

const x: number = xs[3]; // = "BOOM" (矛盾)

しかし最初に述べたように, 現実的なシチュエーションとしては, 配列に対して mutable な操作を行うよりも, immutable に扱う方がずっと多いはずです. したがって, 安全性の面で問題があっても, このようなキャストを行うことができた方が利便性の面で勝っていると言えます.

これを実現するため, TypeScript はメソッドを引数について bivariant なものとして扱います. これによって push() のようなメソッドがあってもキャストができるようになっているというわけです.

(かつてはメソッドではない関数も引数について bivariant でしたが, これはデフォルトで contravariant となりました. 現在も strictFunctionTypes というオプションの形でこの名残があります.)

このような問題が発生するのは, 標準のデータ型では

  • Array<T> (別記法: T[])
  • Map<K, V>
  • Set<T>

あたりでしょう. これらはそれぞれデータを mutable に変更するメソッドを持っていますが, 先の場合と同じく, mutable な操作は原則として避けつつ, キャストが行われていないことが保証できる場合のみ例外として許容するようにしましょう.

加えて mutable な操作を行うメソッドを省いた読み取り専用の型も予め用意されているので, 積極的に活用しましょう.

  • ReadonlyArray<T> (別記法: readonly T[])
  • ReadonlyMap<K, V>
  • ReadonlySet<T>

自分でデータ型を実装するような場合も,

  • 必要のない mutable な操作は用意せず, できるだけ immutable な設計をする
  • 可能であればメソッドは避けて, 代わりにプロパティを使う

といったことに気をつけましょう. ただし素朴に class を書いた場合はメソッドが避けられなかったり, interface の定義においてメソッドの方が意図をより正確に表現できるといった場合もあり, なかなかメソッドを完全に排除するのは難しいところですね.

次回

susisu.hatenablog.com