LoginSignup
88
58

More than 3 years have passed since last update.

TypeScript + React で作る checkbox の checked と defaultChecked を型で「排他」にしてエラー検知しやすくした話

Last updated at Posted at 2019-06-02

checkbox を React で書いたことありますか?

ちょっとサンプルを書いてみたので、ご覧ください。

checkbox.tsx
import React, { FC } from 'react';

interface Props {
  checked?: boolean;
}

const Checkbox: FC<Props> = ({ checked = false }) => (
  <input type="checkbox" checked={checked} />
);

export default Checkbox;

超最低限で書くと、こんな感じになります!
checked という Props を用意して、それを <input> に流しています。

流れてくる boolean によって、 checkbox の check 状態を制御できるようになっているわけです。

外部から <Checkbox checked={true} /> とすれば「check された状態」になりますし、
<Checkbox checked={false} /> とすれば「check されていない状態」になります。

つまり、これはどういうことかというと、、、

どんなに checkbox をクリックされても boolean の値は変更されないため、true であれば永遠に check された状態になるということです。

ここが、 React の フォーム系 UI を実装するときの混乱するポイントの一つです。。。

checkeddefaultChecked

checkbox には checked に影響する Props が2つ存在します。

checked というのは、 チェックされているか否かの状態が boolean 値によって常にコントロールされているときに使う Props です。つまり先ほどの例のような状態です。
値が固定されていたら、どんなにユーザが操作しても値が変わらない状態です。
このコンポーネントのことを、制御されている状態のコンポーネントということで、 Controlled Components と呼びます。

一方、defaultChecked は、従来の HTML のようにユーザ操作によって、チェックされている状態を変更できる状態にしておく Props です。
一見、defaultCheckedの方が扱いやすいイメージがありますが、 defaultChecked だと初回の render 時に流された boolean の値で UI が決定したあと、 defaultChecked に値を流し直しても UI は変更されません。
なので、 Controlled Components に対して、こちらは Uncontrolled Components と呼びます。

どちらを使ってももちろん良いですし、その時折でどちらを使うかを判断する必要があるとは思いますが、、、

少なくとも、両方を同時に使うことはありません!

「コントロールしないのにコントロールする」ってもう破綻していますもんね。

checkeddefaultChecked を同時に指定すると、 React も Warning を吐いてくれます。

Warning: Checkbox contains an input of type checkbox with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://fb.me/react-controlled-components

いっぱい英語で書かれていますけど、 but not both と書かれているので、今回のことを言っている Warning で間違いないでしょう!

(より詳しく Uncontrolled Components について知りたい場合は以下を参考にしてください。)
Uncontrolled Components – React

ならば、片方しか指定できないようにしたい!

やっと本題です!笑

Checkbox というコンポーネントを使いたいときに、 checkeddefaultChecked両方の props を流そうとしたら「型エラー」が出るように型で縛っちゃえばいいと思いませんか?

ひょっとしたら、これは人類史上最大の発明をしてしまったかもしれない。。。

checkeddefaultChecked という2つの Props がある時点で混乱するのに、これを両方指定すると Warning が出てしまうとなったら、もう混乱に混乱を重ねてしまって大変な騒ぎです!!!

つまり、
- <Checkbox checked={true} />OK
- <Checkbox defaultChecked={true}>OK
- <Checkbox checked={true} defaultChecked={true} />NG

となればいいわけです。

タイトルの「排他」は少し語弊がありますね。
正確に言うと、「否定論理積」の振る舞いをさせたいということです。

そんなことできんのか!?という感じですが、 never というのを使えばいけちゃうそうです。

never を使って checkbox の型を正しく縛ろう!

まずは、never は使わずに Checkbox Component を作ってみます。

checkbox.tsx
import React, { ReactNode, FC, ChangeEvent } from 'react';

interface Props {
  checked?: boolean;
  defaultChecked?: boolean;
  onChange?(event: ChangeEvent): void;
  chidlren?: ReactNode;
}

const Checkbox: FC<Props> = ({
  checked,
  defaultChecked,
  onChange = () => {},
  children
}) => (
  <label className="checkbox">
    <input
      type="checkbox"
      checked={checked}
      defaultChecked={defaultChecked}
      onChange={event => onChange(event)}
      className="checkbox__main"
    />
    <span className="checkbod__label">{children}</span>
  </label>
);

export default Checkbox;

ちゃんと onChangechildren も渡せるようにしてみました。
こうしておけば、 onChangechecked の boolean も変えやすくなりますし、 checkbox の横に文字を入れることもできます。

ただ、この状態で Checkbox を使うと、、、

const App => () => <Checkbox checked={true} defaultChecked={true} />

利用する側は、上記のように checkeddefaultChecked のどちらを入れてもなんのエラーもなくコンパイルできてしまいます。

これを never で防いでいきます。

never を入れた型を作っていく

詳しくは、こちらの記事を読んでいただければと思いますが、 never という型があり、その記事から引用すると

属する値が存在しない型

とのことです。「無が有る」みたいですね。難しい。。。

難しいのですが、この never を使うと、そこに何かしらの値が入ると型エラーが起こります。
なんでもです。nullundefined もエラーです。
この特性をうまく活用していく形になります。

まずは、 Controlled Components でも Uncontrolled Components でも両方で使う Props の型を定義します。

interface BaseProps {
  onChange?(event: ChangeEvent): void;
  chidlren?: ReactNode;
}

そして、 Controlled Components 用の Props の型と、 Uncontrolled Components 用の型を用意していきます。
両方とも、共通で使う BaseProps を extends させておきます。

interface ControlledProps extends BaseProps {
  checked?: boolean;
  defaultChecked?: never;
}

interface UncontrolledProps extends BaseProps {
  checked?: never;
  defaultChecked?: boolean;
}

ここで never 型が出てきました。

ControlledProps では defaultCheckednever なので、 defaultChecked に何かしら指定すると怒られます
defaultChecked: never にすると、指定しなかったとしても怒られてしまうのですが、undefined を許容してそれを回避しています。

UncontrolledProps は、ControlledPropsの逆を書いておきます。

そして、この2つの Props を、、、

type Props = ControlledProps | UncontrolledProps;

Union 型にしてしまいます!!!

Union 型とは、string | number の場合、どちらでも良いよ〜という型になるということです。
つまり、Props という型は、ControlledProps でも UncontrolledProps でもどちらでも良いよ〜という型になったということです。

すべてのコードをまとめると、以下のようになります。

checkbox.tsx
import React, { ReactNode, FC, ChangeEvent } from 'react';

interface BaseProps {
  onChange?(event: ChangeEvent): void;
  chidlren?: ReactNode;
}

interface ControlledProps extends BaseProps {
  checked?: boolean;
  defaultChecked?: never;
}

interface UncontrolledProps extends BaseProps {
  checked?: never;
  defaultChecked?: boolean;
}

type Props = ControlledProps | UncontrolledProps;

const Checkbox: FC<Props> = ({
  checked,
  defaultChecked,
  onChange = () => {},
  children
}) => (
  <label className="checkbox">
    <input
      type="checkbox"
      checked={checked}
      defaultChecked={defaultChecked}
      onChange={event => onChange(event)}
      className="checkbox__main"
    />
    <span className="checkbod__label">{children}</span>
  </label>
);

export default Checkbox;

Union 型として定義した Props は Checkbox Component の Props として型定義してあげます。

こうしてあげると、なんと「否定論理積」のような振る舞いをしてくれます!!

実際に使ってみる

というわけで、実際に import して、 checkeddefaultChecked の2つを指定してみましょう。

const App = () => (
  <Checkbox checked={true} defaultChecked={true} />
);

こう書いてみると、、、

Type '{ checked: true; defaultChecked: true; }' is not assignable to type '(IntrinsicAttributes & ControlledProps & { children?: ReactNode; }) | (IntrinsicAttributes & UncontrolledProps & { children?: ReactNode; })'.
  Type '{ checked: true; defaultChecked: true; }' is not assignable to type 'UncontrolledProps'.
    Types of property 'checked' are incompatible.
      Type 'true' is not assignable to type 'undefined'.

というエラーが出ました!!!

エラーの中身は複雑になっちゃっていますが、両方指定するとちゃんとエラーが出ることは確認できました!!!

取り急ぎ、やりたいことはできました!

以下のときにはちゃんとエラーは出ないので、大成功というわけですね!

const App = () => (
  <>
    <Checkbox />
    <Checkbox checked={true} />
    <Checkbox defaultChecked={true} />
  </>
);

まとめとか解説とか謝辞とか

もともとこの記事は Type Guard の記事として書こうとしていたのですが、試しているうちに、ControlledPropsUncontrolledProps を Union 型にするだけで今回のやりたいことが満たせてしまったので大変びっくりしました。。笑

最初、記事を書く前にテストで書いていたコードには、

function checkControlledProps(props: Props): props is ControlledProps {
  return typeof props.checked === "boolean";
}

という関数が書いてあって、「 checked に boolean がセットされていれば ControlledProps とみなしますよ!という関数を書いて、今回の問題を解決します!」という記事にする予定でしたw

ただ、

type Props = ControlledProps | UncontrolledProps;

と書いているときに、これってどういう型なんだ?と思い始めて、思い切って Type Guard の関数を削除してみたら、結局同じエラーを吐いたので急遽 Type Guard には言及しない記事にしましたw

Type Guard に関しては以下の記事が大変わかりやすいので、ぜひ読んでみてください!笑
TypeScript の Type Guard を使ってキャストいらず

そして、肝心の、どうして今回うまくできたのか、についてですが、
おそらく Union 型にしたことによって、 checkedboolean を流せば自動的に ControlledProps が採用されて、 defaultCheckednever | undefined が採用されたんだろうなと思いました。
わざわざ関数を作って判別しなくても、Union 型のどちらが採用されるべきかは明確になるので、今回のやりたかったことができたのだろうなと思っています。

最後、記事を書いておきながら、あれ?となってしまったので、なにかご指摘があればコメント欄にご記載くださいw

また、今回のこの記事は私一人の力で生み出したわけではなく、以前会社で助っ人としてアサインされた方に教えてもらったものを参考に書いております!
教えてもらえて超感謝しています!

以上です!

88
58
2

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
88
58