【TypeScript】プロパティの共通部はそのまま、差異のある部分は元の型かundefinedとなるゆるいユニオン型っぽい型の作り方

 TypeScriptはJavaScriptを拡張した言語であり型の機能を提供してくれます。この型の定義は様々なことができます。
 
 TypeScriptには型と型のORでつないだ型であるユニオン型というものがあります。これは次のように動作します。
ユニオン型 (union type) | TypeScript入門『サバイバルTypeScript』

// maybeUserId は string か null であると示す
const maybeUserId: string | null = localStorage.getItem("userId");

 ユニオン型は便利なのですが、TypeScriptの堅牢さ故になんとなく期待通りでない挙動をすることもあります。自分にとってのこれが次の例です。

type User = {
  userId: string;
  name: string;
  age: number;
};

type DummyUser = {
  dummyUserId: string;
  name: string;
  age: number;
};

const echoUser = (user: User | DummyUser): void => {
  console.log(user.userId); // userId: string|undefined となって正常動作することはなく、ここでエラーになる
  // TS2339: Property userId does not exist on type User | DummyUser
  // Property userId does not exist on type DummyUser
};

 変数userはUser型かDummyUser型のどちらかである、と引数に記述しuserIdを参照しようとしています。User型のuserIdはstringであり、DummyUser型のuserIdは未定義なのですから、変数userからuserIdを参照した際の型はstringかundefinedになることを期待します。オブジェクトに存在しないプロパティを参照してもエラーにならずundefinedがそこにあるだけとなって欲しかったわけです。しかしながらTypeScriptは堅牢であり、DummyUser型に存在しないプロパティであるuserIdをそのまま参照することは許されません。この挙動をTypeScriptの堅牢さをある程度、維持しつつ扱いやすいように変える方法を紹介します。

 この問題の解決方法ですが、まずTypeScriptの用意してくれたやり方があります。それは型ガードです。

 型ガードはif文などの制御フローによって型を絞り込める場合、TypeScriptが型を自動で絞り込んでくれる機能です。これは次のように動きます。
制御フロー分析と型ガードによる型の絞り込み | TypeScript入門『サバイバルTypeScript』#型ガード

const echoUser = (user: User | DummyUser): void => {
  if ('userId' in user) { // ここで user の中のプロパティに userId があるかどうかをチェック
    // ↑がtrueなのでuserIdがuserの中にあるとわかる、userはUser型であると識別される
    console.log(user.userId); // エラーにならなくなる
  }
};

 上記例は一つのプロパティの有無だけでしたのでin演算子を使いましたが、より複雑な条件を使う場合は型ガード関数の機能を利用した方が便利です。型ガード関数は次のように動きます。
型ガード関数 (type guard function) | TypeScript入門『サバイバルTypeScript』

// User型であることを示すための型ガード関数
// 返り値がbool型の関数の返り値の型を「引数 is 型」とすることで
// この関数の返り値がtrueであるならば引数の型はこれであるとTypeScriptに示せます。
const isUser = (user: User | DummyUser): user is User => {
  return "userId" in user;
}
const echoUser = (user: User | DummyUser): void => {
  if (isUser(user)) { // 型ガード関数でチェック
    // ↑がtrueなのでuserはUser型であると識別される
    console.log(user.userId); // エラーにならなくなる
  }
};

 上述したTypeScriptに組み込まれて用意されている型ガード機能で概ね足りるのですが、これは変数の型が何であるかを明らかにするための機能であり、冒頭でやりたかった「オブジェクトに存在しないプロパティを参照してもエラーにならずundefinedがそこにあるだけ」という動きには少々向きません。これにより適した形にするにはこちら側で型を定義する方法が取れます。これは例えば次のようにできます。

const echoUser = (user: (User | DummyUser) & Partial<User & DummyUser>): void => {
  console.log(user.userId); // エラーにならなくなる
};

 共通するプロパティは必ずあるものとし、共通でないプロパティはundefinedかもしれない、と定義した型です。厳密には違うのですが概ねこの説明の通りに動きます。もし型が複数に増えても楽に書きたい場合は次の様に型を定義することができます。

type PartialUnion<T extends unknown[]> =
  // Tの各要素に対して...
  T[number] &
    // その要素と、UnionToIntersectionを使用して得られる交差型の部分型を交差させる
    Partial<UnionToIntersection<T[number]>>;
type UnionToIntersection<U> =
  // Uがobjectである場合、関数型にマッピングします。そうでない場合、never型を返します。
  (
    U extends object ? (k: U) => void : never
  ) extends // 上記のマッピングの結果から、インファレンスを使用して実際の交差型を取得します。
  (k: infer I) => void
    ? I
    : never;
// 三つ目以降の型も配列に追加できます
const echoUser = (user: PartialUnion<[User, DummyUser]>): void => {
  console.log(user.userId); // エラーにならなくなる
};

 この型を合成する方法の注意点としてプロパティの有無やその型による型ガード(特にin演算子やtypeof演算子)がうまく機能しなくなるという問題があります。型の流れを壊しやすいので、使うのであれば画面表示部などの処理のフローの末端が適切です。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG