Omit<Type, Keys>が(期待どおりに)機能しないとき
2022-07-12
TypeScriptのOmit<Type, Keys>が意図したとおりに機能せず頭を抱えてしまいました。
このブログ投稿はOmitが機能しない理由を考えることで学んだことを共有します。
Omit<Type, Keys>とは?
Omit<Types, Keys>はTypeScriptが提供するユーティリティ型です。
Typeのすべてのプロパティをすべて持つがKeysに指定したプロパティは欠く新しい型を定義します。
KeysをTypeから削除する型演算子と見ることができます。
以下の定義があるとすると、
;
導出されたPersonは以下と等価です。
Omit<Type, Keys>が機能しないとき
自分のプロジェクト外に以下のような型が定義されていると想定します。
InfoObjectのtitleプロパティをオプションにしたいとします。
そこでInfoObjectからOmitを使って新しい型を導出します。
;
これでInfoObjectOmittingTitleのインスタンスを初期化する際にtitleプロパティを省略することができます。
; // エラー! error TS2741: Property 'title' is missing in type '{ version: string; }' but required in type 'InfoObject'.
; // OK!
しかしおかしなことが起こります。
期待に反してversionプロパティもオプションになっているのです。
; // OKなの!?
さらには、以下ですらコンパイルできてしまいます!
; // OKなの!?
Omit<Type, Keys>はどのように機能するか
Omit自体の定義はTypeScriptソースコードのこちらにあります。
以下はコードの抜粋です。
;
Omitは実際のところPickとExcludeという別のユーティリティ型を組み合わせたものです。
Pick<Type, Keys>とは?
PickはOmitの反対です。
TypeのプロパティでKeysに含まれるものを持つ新しい型を定義します。
Pick自体の定義はこちらにあります。
以下はコードの抜粋です。
;
Exclude<UnionType, ExcludedMembers>とは?
ExcludeはUnionTypeのすべてのメンバーを持つがExcludedMembersに含まれるものは除外する新しいユニオン型を定義します。
Exclude自体の定義はこちらにあります。
以下はコードの抜粋です。
;
私の失敗例では何が起きているのか?
前の節の以下の部分を詳しく分解していきます。
Omit<InfoObject, 'title'>
これは以下のようになります。
Pick<InfoObject, Exclude<keyof InfoObject, 'title'>>
keyof InfoObjectは以下のユニオン型と等価です。
keyofがどのように機能するかについてはこちらのページを参照ください。
'title' | 'version' | 'description' | string;
stringはベースインターフェイスIElementのIndex Signature [extensionName: string]: anyからきています。
ということでOmit部分はさらに以下のようになります。
Pick<InfoObject, Exclude<'title' | 'version' | 'description' | string, 'title'>>
以下のテーブルは'title' | 'version' | 'description' | stringの各メンバーTについてExclude<T, 'title'>を評価した結果を示しています。
Exclude<'title' | 'version' | 'description' | string, 'title'>の部分で何が起きているのかを知るには"Distributive Conditional Types"を理解しなければなりません。
T | T extends 'title' ? | Exclude<T, 'title'> |
|---|---|---|
'title' | true | never |
'version' | false | 'version' |
'description' | false | 'description' |
string | false | string |
つまりExclude<'title' | 'version' | 'description' | string, 'title'>は以下のようになります。
'version' | 'description' | string
そしてOmit部分は以下のようになります。
Pick<InfoObject, 'version' | 'description' | string>
最終的なPickでは何が起きるでしょうか?
ここは結果から推測することしかできません。
Pick<T, K>の展開の際にK extends keyof TもしくはP in Kで最も包括的なstringが優先し、より限定的なメンバーの'version'と'description'が抑え込まれるのではないかと思われます。
結果的にPickは以下のようになります。
しかし、私の推測をバックアップする根拠となる文献はまだ確認できていません。
回避策
こちらの記事によると、以下が回避策になりそうです。
;
Partial<Type>はTypeのすべてのプロパティをオプションとする新しい型を定義します。
Pick<InfoObject, 'version'>とのインターセクションによりversionプロパティが必須のままになります。
主な難点は必須プロパティを残さず列挙しなければならないことです。
結論
Omit<Type, Keys>はTypeがIndex Signatureを含むと機能しないかもしれません。
なぜこれが起きるのかTypeScriptの仕様からは確認できていません。
しかし回避策は見つかりました。