Omit<Type, Keys>が(期待どおりに)機能しないとき

2022-07-12

thumbnail

TypeScriptOmit<Type, Keys>が意図したとおりに機能せず頭を抱えてしまいました。 このブログ投稿はOmitが機能しない理由を考えることで学んだことを共有します。

Omit<Type, Keys>とは?

Omit<Types, Keys>TypeScriptが提供するユーティリティ型です。 Typeのすべてのプロパティをすべて持つがKeysに指定したプロパティは欠く新しい型を定義します。 KeysTypeから削除する型演算子と見ることができます。

以下の定義があるとすると、

interface IdentifiedPerson {
  id: number;
  name: string;
}
type Person = Omit<IdentifiedPerson, 'id'>;

導出されたPersonは以下と等価です。

interface Person {
  name: string;
}

Omit<Type, Keys>が機能しないとき

自分のプロジェクト外に以下のような型が定義されていると想定します。

interface IElement {
  [extensionName: string]: any;
}

interface InfoObject extends IElement {
  title: string;
  version: string;
  description?: string;
}

InfoObjecttitleプロパティをオプションにしたいとします。 そこでInfoObjectからOmitを使って新しい型を導出します。

type InfoObjectOmittingTitle = Omit<InfoObject, 'title'> & {
  title?: string; // オプションになる
};

これでInfoObjectOmittingTitleのインスタンスを初期化する際にtitleプロパティを省略することができます。

const info: InfoObject = {
  version: '0.0.1',
}; // エラー! error TS2741: Property 'title' is missing in type '{ version: string; }' but required in type 'InfoObject'.

const info2: InfoObjectOmittingTitle = {
  version: '0.0.1',
}; // OK!

しかしおかしなことが起こります。 期待に反してversionプロパティもオプションになっているのです。

const info3: InfoObjectOmittingTitle = {}; // OKなの!?

さらには、以下ですらコンパイルできてしまいます!

const info4: InfoObjectOmittingTitle = {
  version: 123,
}; // OKなの!?

Omit<Type, Keys>はどのように機能するか

Omit自体の定義はTypeScriptソースコードのこちらにあります。 以下はコードの抜粋です。

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Omitは実際のところPickExcludeという別のユーティリティ型を組み合わせたものです。

Pick<Type, Keys>とは?

PickOmitの反対です。 TypeのプロパティでKeysに含まれるものを持つ新しい型を定義します。 Pick自体の定義はこちらにあります。 以下はコードの抜粋です。

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

Exclude<UnionType, ExcludedMembers>とは?

ExcludeUnionTypeのすべてのメンバーを持つがExcludedMembersに含まれるものは除外する新しいユニオン型を定義します。 Exclude自体の定義はこちらにあります。 以下はコードの抜粋です。

type Exclude<T, U> = T extends U ? never : T;

私の失敗例では何が起きているのか?

前の節の以下の部分を詳しく分解していきます。

Omit<InfoObject, 'title'>

これは以下のようになります。

Pick<InfoObject, Exclude<keyof InfoObject, 'title'>>

keyof InfoObjectは以下のユニオン型と等価です。 keyofがどのように機能するかについてはこちらのページを参照ください。

'title' | 'version' | 'description' | string;

stringはベースインターフェイスIElementIndex 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"を理解しなければなりません。

TT extends 'title' ?Exclude<T, 'title'>
'title'truenever
'version'false'version'
'description'false'description'
stringfalsestring

つまり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'が抑え込まれるのではないかと思われます。

{
  [p: string]: InfoObject[string];
}

結果的にPickは以下のようになります。

{
  [extensionsName: string]: any;
}

しかし、私の推測をバックアップする根拠となる文献はまだ確認できていません。

回避策

こちらの記事によると、以下が回避策になりそうです。

type InfoObjectWithOptionalTitle = Partial<InfoObject> & Pick<InfoObject, 'version'>;

Partial<Type>Typeのすべてのプロパティをオプションとする新しい型を定義します。 Pick<InfoObject, 'version'>とのインターセクションによりversionプロパティが必須のままになります。

主な難点は必須プロパティを残さず列挙しなければならないことです。

結論

Omit<Type, Keys>TypeIndex Signatureを含むと機能しないかもしれません。 なぜこれが起きるのかTypeScriptの仕様からは確認できていません。 しかし回避策は見つかりました。

参考