LoginSignup
8
9

More than 1 year has passed since last update.

【typescript】2種類の値の片方がもう一方の型を決定する型を作る【Discriminated unions】

Posted at

何がしたかった

2種類のフォームがあるとします。

その二つは収集する情報が違うので入力された値を保持するオブジェクトには別の型が付いています。

//名前と年齢を収集するフォーム
type FormValuesA = {
    name :string,
    age: number
}

//名前とメールアドレスを収集するフォーム
type FormValuesB = {
    name :string,
    email: string
}

具体的には今回この2種類のフォームのどちらでも受け入れて、
条件分岐でそれぞれ定義したvuexのstateに保存する関数が書きたいと思ってこんな感じの関数を書きました。

type FormType = "formA" | "formB"
type FormValues = FormValuesA | FormValuesB

type FormObj = {
    formType : FormType,
    formValues: FormValues
}

const setFormValues = (formObj: FormObj) => {
    if (formObj.formType === 'formA')
      // 実際にはここでformA用stateに格納
      console.log("you can NOT see", formObj.formValues.age)
    else if (formObj.formType === 'formB')
      // 実際にはここでformB用stateに格納
      console.log("you can NOT see", formObj.formValues.email)
  }

しかし…

スクリーンショット 2021-10-03 182428.png

TS playgroundでの再現コード

上のTS playgroundを開くと実際のエラーが見れますが、人間には条件分岐してるように見えても、typescriptさんはどっちのフォームが入ってくるか分からないので、そこに.ageとか.emailあるか分からんよ〜と注意してくれます。

渡す値の形式を変える方向も考えましたが、他の人が作った関数だったので出来るだけ関数の中だけで型チェックを上手いことやりたいなぁと思いしばらくごちゃごちゃ探し回り、
その日はtype predicatesという機能を発見してこれだー!と喜んで解決したことにしました。

type predicatesとは

arg is 型名構文を関数の戻り値の部分につけることで、ユーザー定義の型ガード用関数を作れるという機能です。

今回の例に適用すると、isFormAisFormBという型ガード用の関数を用意し、if文の中で実際の条件判定に並べて型チェックすることでtypescriptエラーを消すことに成功しました。

function isFormA(
  arg: FormValues,
  formType: FormType,
): arg is FormValuesA {
  return formType === 'formA';
}
function isFormB(
  arg: FormValues,
  formType: FormType,
): arg is FormValuesB {
  return formType === 'formB';
}

 const setFormValues = (formObj: FormObj) => {
    if (isFormA(formObj.formValues, formObj.formType)){
      console.log("you can see", formObj.formValues.age)
      }
    else if (isFormB(formObj.formValues, formObj.formType)) {
      console.log("you can see", formObj.formValues.email)
    }
  }

TS playgroundでの再現コード

見事に型ガードが効いています!これさえあれば無敵!

…と思ったのですが、家に帰って調べたら案の定無敵すぎてかなり取扱注意だという記事がありました。

所詮はアサーションであり、実際の型を無視して黒を白と言えてしまうので、よほど引数が動かし難い理由がなければ使わない方が良さそうです。

というわけでドキュメントを見てたら、その下に正解がありました。

正解はDiscriminated unions

この機能は、2種類のオブジェクト型の中に共通のプロパティ(discriminant property)がある場合に、typescriptが条件分岐内のプロパティを型推論してくれるという機能です。

先の例だとこうなります。

関数内がクリーンかつ、感覚的にも分かりやすいコードになりましたね。

type FormObjA = {
    formType : "formA",
    formValues: FormValuesA
}

type FormObjB = {
    formType : "formB",
    formValues: FormValuesB
}

type FormObj = FormObjA | FormObjB

 const setFormValues = (formObj: FormObj) => {
    if (formObj.formType === 'formA')
      console.log("you can see", formObj.formValues.age)
    else if (formObj.formType === 'formB')
      console.log("you can see", formObj.formValues.email)
  }

TS playgroundでの再現コード

ちなみに注意点として、条件分岐前に分割代入すると上手く型推論が働かなくなります。

// これはダメ😵

const setFormValues = ( {formType, formValues } : FormObj) => { 
    // const {formType,formValues } = formObj  関数の中で分割代入しても同じくエラー
    if (formType === 'formA')
      console.log("you can NOT see", formValues.age)
    else if (formType === 'formB')
      console.log("you can NOT see", formValues.email)
  }

さて問題は無事解決ですが、フォームが増えた時を考えると、一つ一つフォームごとの型を定義していくのはちょっとダルいので、ジェネリック型を使ってFormObj型のunionに追加していけば良いようにします。

type Form<T extends string, V extends {}> = {
  formType: T;
  formValues: V;
};
type FormObj =
  | Form<'formA', FormObjA>
  | Form<'formB', FormObjB>;

完成コード

type FormValuesA = {
    name :string,
    age: number
}

type FormValuesB = {
    name :string,
    email: string
}

type Form<T extends string, V extends {}> = {
  formType: T;
  formValues: V;
};
type FormObj =
  | Form<'formA', FormValuesA>
  | Form<'formB', FormValuesB>;

 const setFormValues = (formObj: FormObj) => {
    if (formObj.formType === 'formA')
      console.log("you can see", formObj.formValues.age)
    else if (formObj.formType === 'formB')
      console.log("you can see", formObj.formValues.email)
  }

こうやってunion型で追加していくと、このsetFormValuesを呼び出す際にも、formTypeと実際のフォームの種類がズレていた場合はtypescriptが教えてくれるので脳死でコーディングできます。ありがたいですね。

    setFormValues({
      formType: 'formA',
      formValuesObj: actualFormValues, // ここの値がformBのものだとエラー!
    });

その他考えたことメモ

これってそもそもformType渡す必要あるのか?という疑問は考えているあいだ度々脳裏をよぎったのですが、
一応「フォームの項目はすべて同じだけど、フォーム自体は違う」というタイプのフォームが2つ以上現れた場合に、そうなるとやはり呼び出し側からstateのセット先を指定する必要があります、よね…?
まぁとりあえずこのやり方ならFormCを足したら条件分岐内にformCValues以外の引数は入ってこないことが保証されます。

FormCを追加したTS playgroundでの再現コード

その他にも、こういう色々策を弄する必要があるときは大抵もっと大元の組み立てが間違ってると思うので、この場合はフォームのstateを一つのstoreで管理するべきなのか?とか、vuex actionを一つの関数にまとめる必要があるのか?とか、(特に前者を)もう少し考えたほうが良い気もするけど。
まぁとりあえず今回はtypescriptの機能で綺麗に解決できたので、良いことにします。

お読みいただきありがとうございました!

8
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
9