🗽

React と相性のいいユニオン型

2023/02/04に公開

TL;DR

React でユニオン型を使うときは、次のユーティリティ型 RUnion を使うといいです。

type KeyofUnion<T> = T extends T ? keyof T : never;

type ComposeRUnion<T, U = T> = T extends T
  ? T & Partial<Record<Exclude<KeyofUnion<U>, keyof T>, never>>
  : never;

export type RUnion<T> = ComposeRUnion<T>;

使うときはこんな感じ

type X = RUnion<
  | {
      a: number;
      b: string;
    }
  | {
      c: boolean;
      d: number[];
    }
>;

React でユニオン型がうまく使えないケース

ユーザーの登録ページで使うコンポーネント UserInfoForm を作成する場合を考えてみます。 UserInfoForm は送信ボタンをクリックしたときにデータを渡せるようにしたいので、 props の型とコンポーネントの定義は次のようになるでしょう。

type Props = {
  onSubmit: (data: UserInfo) => void;
}

const UserInfoForm: React.FC<Props> = (props) => {
  ...
};

実装している最中に、 UserInfoForm をユーザの登録だけでなく、ユーザ情報の編集でも使いまわしたいと思うようになりました。 props の型をユニオン型にして、編集モードのときだけ prop で現在登録しているユーザの情報を渡せるようにします。

type Props = {
  onSubmit: (data: UserInfo) => void;
} & (
  | {
      // 新規作成モード
      mode: 'create';
    }
  | {
      // 編集モード
      mode: 'edit';
      defaultValue: UserInfo;
    }
);

しかし、この定義では props.defaultValue へアクセスしたいときに、 props.mode === 'edit' の判定による型ガードが必要で面倒です。たとえば、 react-hook-form というライブラリを使う際に、次のように定義しなければなりません。(参考

const { register, handleSubmit } = useForm<UserInfo>({
  // props.defaultValue にアクセスするために mode === 'edit' でないといけない
  defaultValues: props.mode === 'edit' ? props.defaultValue : undefined
});

また、 eslint の react/destructuring-assignment のルールも適用できないので、泣く泣くルールを無効化するか props の型にユニオン型を使うのを諦めることになります。

// @ts-expect-error TS(2339) Property 'defaultValue' does not exist on type 'Props'.
export const RegisterForm: React.FC<Props> = ({ mode, defaultValue }) => {
  ...
}

対応方法

愚直な対応方法

これまで挙げた問題は、すべて props.mode === 'create' のときに props.defaultValue にアクセスできないことが原因で発生しています。そこで、次のような型の定義を導入してみます。

type Props = {
  onSubmit: (data: UserInfo) => void;
} & (
  | {
      mode: 'create';
      defaultValue?: undefined;
    }
  | {
      mode: 'edit';
      defaultValue: UserInfo;
    }
);

props.defaultValue に常にアクセスできるようにすることで、全ての問題を解消できる型を定義することができました。また、 props.mode === 'create' のときに defaultValue はオプショナルです。 UserInfoFormmode="create" で呼び出すときに defaultValue を指定しなくてもよいので、 defaultValue={undefined} という無駄な指定を強制されないのも使い勝手がいいです。(参考

// いずれの呼び出し方もok
<UserInfoForm mode="create" onSubmit={onSubmit} />
<UserInfoForm mode="create" onSubmit={onSubmit} defaultValue={undefined} />
<UserInfoForm mode="edit" onSubmit={onSubmit} defaultValue={defaultValue} />

ただし、 props.mode === 'create' の型の定義に props.mode === 'edit' だけのプロパティが含まれていることに気持ち悪さがあります。また、ユニオン型で連結する型のプロパティが増えれば増えれるほど、 props の型の定義が複雑になってしまいます。

ユーティリティ型を使った問題解決

次のようなユーティリティ型 RUnion を導入して問題解決ができます。

type KeyofUnion<T> = T extends T ? keyof T : never;

type ComposeRUnion<T, U = T> = T extends T
  ? T & Partial<Record<Exclude<KeyofUnion<U>, keyof T>, never>>
  : never;

export type RUnion<T> = ComposeRUnion<T>;

次のように props の型を定義することで愚直な対応方法で紹介したものと同じ型を定義することができます。

type Props = {
  onSubmit: (data: UserInfo) => void;
} & RUnion<
  | {
      mode: 'create';
    }
  | {
      mode: 'edit';
      defaultValue: UserInfo;
    }
>;
動作原理

Distributive Conditional Types を使って実装しています。 Distributive Conditional Types については、次の記事を参考にしてください。

https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
https://zenn.dev/tnyo43/articles/828898a30e295b#distributive-conditional-types

KeyofUnion<T> は、 Distributive Conditional Types を使って、オブジェクト型のユニオン型 T に対してそれぞれのオブジェクト型の全てのプロパティのキーのユニオン型になります。

type X = 
  | { a: number }
  | { b: boolean }
  | { c: null; d: string }
  | { a: undefined; b: () => void };

type keys = KeyofUnion<X>;
// type keys = 'a' | 'b' | 'c' | 'd';

ComposeRUnion<T>Partial<Record<Exclude<KeyofUnion<U>, keyof T>, never>> の部分を見てみましょう。 ComposeRUnion は Distributive Conditional Types の影響で T は分配されたオブジェクトで、 U は型引数に渡された型そのものです。 Exclude<KeyofUnion<U>, keyof T> は型引数のすべてのプロパティのキーから分配された T のプロパティのキーを除いたものです。
Record<Exclude<KeyofUnion<U>, keyof T>, never で求めたキーに値の型を never としたレコードの型を作ります。
そして Partial でラップすることで T に含まれないキーをすべてのプロパティを持つオプショナルで値の型が undefined なオブジェクト型を作っています。
最後に T との交差型を作ることで、目的の型を作ることができました。

他の活用できるケース

React の useCallbackuseEffect と組み合わせる場合でも RUnion を使うといい場合があります。単純にユニオン型を使ってしまうと型ガードなしでアクセスできないプロパティは depsuseCallbackuseEffect の第二引数の配列)に適用できません。 RUnion を使うと常に全てのプロパティにアクセスできるので deps へ適用できるようになります。
同じ理由で、RUnionuseState で扱う状態やカスタムフックの型に使ってもいいです。次の State 型のオブジェクトは、 loading === true でも data にアクセスすることができます(値は undefined)。

type State = RUnion<
  | {
      loading: true;
    }
  | {
      loading: false;
      error: Error;
    }
  | {
      loading: false;
      error: undefined;
      data: Data;
    }
>;

まとめ

  • React でユニオン型を使う場合は、アクセスできないプロパティがあると扱いづらい
  • RUnion 型を使って全てのプロパティにアクセスできるようにすることで問題を回避できる
  • RUnion はコンポーネントの props の型や state の型に使うとよい

Discussion