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> が便利に使えます
  • その他のオブジェクトが長期間生存するようなケースでは, そもそもオプショナルなプロパティ自体を避けましょう

おわり.

引数の型を推論してから受け付けるかどうかを決める後出しパターン

お題

具体例として, ちょうど長さ 3 の文字列のみを引数として受け付ける関数を作ります. こんな関数を作って何がしたいのかは不明.

先出しパターン

よくある手段としては「ちょうど長さ 3 の文字列」のような制約を満たす型をあらかじめ定義しておいて, 引数の型としてそれを使うという方法です.

type StringOfThreeChars = /* ちょうど長さ 3 の文字列 */;

declare function myFunc(str: StringOfThreeChars): void;

myFunc("ab");   // error
myFunc("abc");  // ok
myFunc("abcd"); // error

では StringOfThreeChars はどう定義するとよいでしょうか? おそらく現時点 (TypeScript 4.2) でできる最大限の努力をすると, 以下のように制約を満たすパターンを union type として列挙しておくことになると思います.

type Char = "\u0000" | "\u0001" | ... | "\uFFFF";
type StringOfThreeChars = `${Char}${Char}${Char}`;

こんなの絶対書きたくないですし, そもそも StringOfThreeChars はパターン数が多すぎるのでコンパイラが爆発すると思います (試してないです).

後出しパターン

こんなときには別の方法があります (Playground).

type StringOfThreeChars<S extends string> = /* S の長さが 3 かの判定 */ ? S : never;

declare function myFunc<S extends string>(str: StringOfThreeChars<S>): void;

myFunc("ab");   // error
myFunc("abc");  // ok
myFunc("abcd"); // error

まずはこの関数を myFunc("abc") のように呼び出したとき, TypeScript がどのような処理を行うかを見てみましょう.

最初に引数 "abc" から型引数 S を推論しようとします. 引数の型は conditional type なのですが, このような場合は結果の型が S または never であることを使って, S = "abc" とうまく推論してくれます. S extends string としているので widening (リテラル"abc"string に拡大される) は起こりません.

無事 S が推論されたので, 続けて引数の型 StringOfThreeChars<S> を計算します. S = "abc" の場合はちょうど長さ 3 なので StringOfThreeChars<S> = "abc" となり, myFunc("abc") という呼び出しは型が矛盾しないので受け付けられることになります.

では myFunc("ab") のように長さが 3 でない文字列で呼び出された場合はどうなるかというと, S = "ab" が推論され, StringOfThreeChars<S> = never となります. "ab"never に代入できないので, この関数呼び出しは受け付けられません.

ということでこの方法で引数の型に制約をつけられることがわかりましたが, では制約の判定を行うにはどうしたらよいでしょうか. もしここであらかじめ制約を満たすものを列挙しないといけないのであれば, 先出しパターンでの欠点は克服できません.

type _StringOfThreeChars = /* ちょうど長さ 3 の文字列 (定義不可能) */;

type StringOfThreeChars<S extends string> = S extends _StringOfThreeChars ? S : never;

重要になるのは, ここでは引数の型 S が具体的に得られているので, それが制約を満たしているかの判定が行えれば十分ということです. そして TypeScript の型はとても表現力が高いので, 文字列の長さが 3 かどうかという判定は記述することができてしまいます.

// 文字列の長さのカウント (説明省略)
type CountChars<S extends string, C extends unknown[] = []> =
    string extends S ? number
  : S extends "" ? C["length"]
  : S extends `${infer A}${infer B}` ? CountChars<B, [...C, unknown]>
  : never;

type StringOfThreeChars<S extends string> = CountChars<S> extends 3 ? S : never;

よかったですね.

後出しパターンの欠点

myFunc("ab") のように制約を満たさないときのエラーは Argument of type 'string' is not assignable to parameter of type 'never'. のような, 制約が何であるのかを含まない不親切なものになってしまうことには注意が必要です. 将来的に throw types のようなものが実装されれば, この点は解消されるかもしれません.