exactOptionalPropertyTypes によせて

TypeScript 4.4 に exactOptionalPropertyTypes というオプショナルなプロパティに関するコンパイラオプションが追加されるのを受けて, そもそもオプショナルなプロパティとは何なのか, どういったときに使うと良いのか, exactOptionalPropertyTypes がどう嬉しいのかを考えてみます.

あらかじめ私個人の立場を明らかにしておくと, 型による安全性を重視する傾向があります (やや過激派寄り).

exactOptionalPropertyTypes については GitHub の discussion issue での議論も参考になるかもしれません.

前提

そもそもオプショナルなプロパティとは

オプショナルなプロパティは, オブジェクト型のプロパティのうち, key?: Type のように ?: で宣言されて, そのプロパティが省略可能であることを表すものです.

type Foo = {
  keyA: number;
  keyB?: string;
};

以下のように関数へ引数を渡すときや変数に代入するときに, プロパティには Type だけでなく undefined を渡したり, あるいはプロパティの存在ごと省略することができます.

declare function doFoo(foo: Foo): void;

doFoo({ keyA: 42, keyB: "xxx" }); // OK
doFoo({ keyA: 42, keyB: undefined }); // OK
doFoo({ keyA: 42 }); // OK

プロパティを参照するときは Type | undefined という型になります.

declare const foo: Foo;
// b: string | undefined
const b = foo.keyB;

オプショナルなプロパティの危険性

さて皆さんご存知の通り TypeScript には構造的部分型という仕組みがあるので, 以下のようにプロパティの数が減る方向へのキャストは合法的に行うことができます. これはいくつかの例外的な状況を除いて, オブジェクトのいくつかのプロパティを無視して扱うことは安全であるためです.

const x: { keyA: number; keyB: boolean } = { keyA: 42, keyB: true };
const y: { keyA: number } = x; // OK

const a = y.keyA; // OK
const b = y.keyB; // Error

この例外的な状況というのはざっくり言えば二種類あって, 一つはオブジェクトのプロパティを列挙する場合と, もう一つはオプショナルなプロパティを扱う場合がそれにあたります. この記事の主題はオプショナルなプロパティについてなので, ここでは後者に注目してみましょう.

実は以下のように, オプショナルなプロパティが増える方向へのキャストは合法ということになっています.

const z: { keyA: number; keyB?: string } = y; // OK

これと先ほどのプロパティが減る方向へのキャストを組み合わせると安全性の問題があることは明らかで, 以下のように z を経由して keyB にアクセスすると, 型の上では string | undefined となっているのに, 実際の値は true であるという矛盾が生じてしまいます.

// b: string | undefined
const b = z.keyB; // = true

困りましたね.

オプショナルなプロパティとうまく付き合っていくには?

上記のような安全性の問題が発生したのは, 安全でないキャストが行われたためでした. このキャストは暗黙的に行われるため, たとえばデータを複数のアプリケーションコンポーネントの間で受け渡していくような場面では, どこかでそのようなキャストがうっかり挟まってしまう可能性があります.

こういった問題を根本から断ち切るためには, そもそもオプショナルなプロパティを使わないという方法が考えられます. オプショナルなデータを表現したい場合には key: Type | undefined のような undefined (または宗派によっては null) との union 型を使います.

type Foo = {
  keyA: number;
  keyB: string | undefined;
};

私の経験上, 複数のコンポーネントでやりとりするような長期間生存するデータについて, このような union 型ではなくオプショナルなプロパティでないと困るというケースにはほぼ遭遇したことがありません.

一方で, オプショナルなプロパティの方が便利なケースはたしかに存在します. ざっと思いつくのは二つで, 一つは関数が名前付きでパラメータを受け取るような場合です.

type Options = {
  optionA?: number;
  optionB?: string;
};

function myFunc(options?: Options): void {
  const a = options?.optionA ?? 0;
  const b = options?.optionB ?? "";
  // ...
}

myFunc();
myFunc({ optionA: 42 });
myFunc({ optionB: "xxx" });

このようなケースであれば, データは通常ごく短期間しか生存せず, キャストが入り込む余地もないため, オプショナルなプロパティを使っても安全と言えるでしょう.

もう一つはオブジェクトの一部のプロパティのみを上書きしてマージするような場合があります. React の setState などが代表的な例ですね.

type Obj = {
  keyA: number;
  keyB: string;
};

// NOTE: Partial<T> は T のすべてのプロパティをオプショナルに変換する
function merge(objA: Obj, objB: Partial<Obj>): Obj {
  return { ...objA, ...objB };
}

const original: Obj = { keyA: 42, keyB: "xxx" };
// obj = { keyA: 42, keyB: "yyy" }
const obj = merge(original, { keyB: "yyy" });

この場合もデータの寿命はごく短いため, 安全性の面での問題はほぼ無く, 適切な使い方であると言えます.

まとめると,

  • 複数のコンポーネントでやりとりするような長期間生存するデータには, もし安全性を重視するのであれば, オプショナルなプロパティを避けるべき
  • 関数の名前付きパラメータやマージされるオブジェクトのように短期間しか生存しないデータには, 安全性の問題は無視できるため, オプショナルなプロパティを使うことができる

というのが私個人の意見です. これに賛同するかどうかは皆さんの自由ですが, TypeScript の仕様上, オプショナルなプロパティを使う場合は, 安全性と利便性を天秤にかけて判断する必要があるということは間違いないでしょう.

exactOptionalPropertyTypes

さて話を戻して, TypeScript 4.4 で追加される exactOptionalPropertyTypes について見てみましょう.

どういう機能?

冒頭で紹介したように, これまでオプショナルなプロパティ key?: Type には, Type または undefined を渡すか, あるいはプロパティの存在ごと省略することができました.

exactOptionalPropertyTypes が有効になっている場合はこの挙動が変更され, Type を渡すまたはプロパティの省略のみが許され, undefined を渡すことができなくなります.

type Foo = {
  keyA: number;
  keyB?: string;
};

declare function doFoo(foo: Foo): void;

doFoo({ keyA: 42, keyB: "xxx" }); // OK
doFoo({ keyA: 42 }); // OK
doFoo({ keyA: 42, keyB: undefined }); // Error

プロパティを参照する場合の挙動は変わりません.

declare const foo: Foo;
// b: string | undefined
const b = foo.keyB;

これまで通り undefined を渡せるようにするには, 以下のように明に union 型を記述する必要があります.

type Foo = {
  keyA: number;
  keyB?: string | undefined;
};

つまり, これまで曖昧だったプロパティの値が undefined であることとプロパティが存在しないことの違いを厳格に区別するようにしよう, というものですね.

何が嬉しいのか?

上で挙げたように, オプショナルなプロパティが比較的安全に使えるのは, データがごく短期間しか生存しない場合でした.

まずは関数の名前付きパラメータを扱う場合を見てみましょう.

type Options = {
  optionA?: number;
  optionB?: string;
};

function myFunc(options?: Options): void {
  const a = options?.optionA ?? 0;
  const b = options?.optionB ?? "";
  // ...
}

myFunc({ optionA: 42 }); // OK
myFunc({ optionA: 42, optionB: "xxx" }); // OK
myFunc({ optionA: 42, optionB: undefined }); // Error

3 つめの呼び出しはこれまではエラーではありませんでしたが, exactOptionalPropertyTypes を有効化したためにエラーになったものです.

この変更は嬉しいのでしょうか? 例えば optionB に渡すための値として, string | undefined 型の変数があったとします.

declare b: string | undefined;

これを exactOptionalPropertyTypes が有効になっている状態で myFunc に渡すにはどうしたらよいでしょうか? 正解は以下です.

myFunc({ optionA: 42, optionB: b }); // Error
myFunc({ optionA: 42, ...(b !== undefined ? { optionB: b } : {}) }); // OK

めんどくさいですね.

また関数の (名前付きでない) オプショナル引数と比較してみると, こちらは以下のように, 引数が渡されなかった場合と undefined を渡した場合を区別しません.

function myFunc2(optionA: number = 0, optionB: string = ""): void {
  console.log(optionA, optionB);
}

myFunc2(42); // 42, ""
myFunc2(42, undefined); // 42, ""
myFunc2(42, "xxx"); // 42, "xxx"

このことを考えると, 名前付き引数についても挙動を揃えて, 省略された場合と undefined を区別しない方が自然で理解しやすいに思われます. そしてこのように区別しない実装をするのであれば, あえて型の上で undefined のみ拒絶するような必要もないでしょう.

こういったことをまとめていくと, 名前付き引数については, 結局 | undefined を明示して, exactOptionalPropertyTypes 以前のデフォルトの挙動に揃えるのが無難な選択肢になるように思われます.

type Options = {
  optionA?: number | undefined;
  optionB?: string | undefined;
};

続いてオブジェクトをマージするような場合を考えてみます.

type Obj = {
  keyA: number;
  keyB: string;
};

function merge(objA: Obj, objB: Partial<Obj>): Obj {
  return { ...objA, ...objB };
}

鋭い読者の皆さんはすでに気がついているかもしれませんが, 従来はこのような実装は安全でありませんでした. 以下のように, オプショナルなプロパティに対して undefined を与えて上書きすることで, 最終的に型とは矛盾した値になってしまうためです.

// obj = { keyA: 42, keyB: undefined }
const obj: Obj = merge({ keyA: 42, keyB: "xxx" }, { keyB: undefined });

この問題は exactOptionalPropertyTypes を有効にすることで綺麗に回避することができます.

merge({ keyA: 42, keyB: "xxx" }, { keyB: undefined }); // Error
merge({ keyA: 42, keyB: "xxx" }, {}); // OK
merge({ keyA: 42, keyB: "xxx" }, { keyB: "yyy" }); // OK

このユースケースには非常に合致していますね.


最後に長期間生存するデータについて見てみましょう. これについてはどうせキャストで破壊できてしまうので, exactOptionalPropertyTypes で厳密に区別したところであまり変わらないように思われます.

const x: { keyA: number; keyB: undefined } = { keyA: 42, keyB: undefined };
const y: { keyA: number } = x;
// z.keyB は存在して値は undefined
const z: { keyA: number; keyB?: string } = y;

先に延べたように, そもそもこういった用途でのオプショナルなプロパティは避けるべきと考えます.

まとめ

オプショナルなプロパティとはそもそもどういったものであったかと, TypeScript 4.4 で追加される exactOptionalPropertyTypes による変化について見てきました.

exactOptionalPropertyTypes が有効になった世界では, 以下のようにオプショナルなプロパティを定義するのが良さそうです.

  • 関数の名前付き引数で用いる場合は, key?: Type | undefined のように undefined を明示しておくのが無難に思われます
  • オブジェクトをマージするような場合は, そのままの key?: Type あるいは Partial<T> が便利に使えます
  • その他のオブジェクトが長期間生存するようなケースでは, そもそもオプショナルなプロパティ自体を避けましょう

おわり.