明日から使えるTypeScriptの応用テクニックその3 -Polymorphic Component編-
前回の記事の続きです。
前回までは、その1でMapped TypesとConditional Typesを、その2でDiscriminated Union TypeとUnion Distributionを見ていきました。
実をいうと本当に書きたかったのは、本稿の「Reactとそのデザインシステムの実装における型定義」という話でした。しかし、そのための前提がいっぱいあって、三部作という長編になってしまいました。
つまり、ここからが本題です。
前段のTypeScriptのテクニックを活かして、まずButtonのコンポーネントを作っていきます。また、Polymorphic Componentというコンポーネントの汎用性を上げるテクニックを取り上げます。最後に、デザインシステムの実装、共通コンポーネントのインターフェースの設計について簡単に触れて終わります。
TypeScriptで型安全にReactコンポーネントを実装する
まず、Buttonコンポーネントを作ってみます。
type ButtonProps = Omit<React.ComponentPropsWithoutRef<"button">, "className">
export const Button: React.FC<ButtonProps> = (props) => {
const { children, ...rest } = props
return (
<button className="ui_button" type="button" {...rest}>
{children}
</button>
)
}
今のところ普通ですが、問題はここからです。
先日、Encraft #6 Focus on UI Component 実装というイベントで、ナレッジワークのよしこさんから『ButtonとLink、どう実装する?』という発表がありました。
アーカイブの動画は、以下にあります。
この動画の1:46ぐらいのところの内容ですが、「ボタンみたいなリンク」の必要性についての話がでてきます。
アクセシビリティのべき論だけでいうなら、リンクは常にリンクらしく、ホバーしたら下線がつく青字の見た目であるべきかもしれません。しかし、動画内でも語られているように、デザイン的にどうしても「見た目はボタンなんだけど挙動はリンク」というコンポーネントが必要になる場合があります。
先ほど作った簡易的なButtonコンポーネントを拡張して、「ボタンみたいなリンク」を作ることはできないでしょうか?
以下ではまず、判別可能なUnion型を使ってそれをやってみたいと思います。
ボタンみたいなリンク
早速ですが、考えられる実装の一つの案は次のようなものです。
type ButtonProps =
| ({
isLink: true
} & Omit<React.ComponentPropsWithoutRef<"a">, "className">)
| ({
isLink?: false
} & Omit<React.ComponentPropsWithoutRef<"button">, "className">)
export const Button: React.FC<ButtonProps> = (props) => {
const { isLink, children, ...rest } = props
if (isLink) {
return (
<a className="ui_button" {...rest}>
{children}
</a>
)
}
return (
<button className="ui_button" type="button" {...rest}>
{children}
</button>
)
}
ここでは、isLink
が判別可能なUnion型における判別のキー(=タグ)となっています。
一見すると問題のないようにも見えますが、この実装はコンパイルエラーになります。理由は、判別可能なUnion型をレスト構文と一緒に使ってしまっているからです。これは、前記事の分割代入-レスト構文の罠で取り上げた問題です。
つまり、このような書き方だと、if (isLink) {
の条件分岐の内側で、restの型がReact.ComponentPropsWithoutRef<"a">
に絞り込まれません。rest
の型は、
| Omit<React.ComponentPropsWithoutRef<"a">, "className", "children">
| Omit<React.ComponentPropsWithoutRef<"button">, "className", "children">
という判別されていないUnion型のままになってしまいます。
そうすると、例えばReact.ComponentPropsWithoutRef<"button">
に含まれるvalue
などは、buttonタグの属性としては正しくても、aタグの属性としては不正なものなので、コンパイルエラーになってしまいます。
実装の改善案は以下のようなものです。
type ButtonProps =
| ({
isLink: true
} & Omit<React.ComponentPropsWithoutRef<"a">, "className">)
| ({
isLink?: false
} & Omit<React.ComponentPropsWithoutRef<"button">, "className">)
export const Button: React.FC<ButtonProps> = (props) => {
const { isLink } = props
if (isLink) {
const { isLink: _, children, ...rest } = props
return (
<a className="ui_button" {...rest}>
{children}
</a>
)
}
const { isLink: _, children, ...rest } = props
return (
<button className="ui_button" type="button" {...rest}>
{children}
</button>
)
}
上のコードでは、レスト構文を用いた分割代入をif (isLink) {
の条件分岐の内側で行うようにしました。それにより、rest
の型がOmit<React.ComponentProps<"a">, "className", "children">
に絞り込まれるため、今度はコンパイルエラーになりません。
ReactRouterのLinkに対応する
ここまでで「見た目はボタンなんだけど挙動はリンク」を実現できました。
しかし、内部リンクに対する対応がまだです。例えば、ReactRouter
のLink
などを使って同一オリジンでページ遷移したいケースもよくあるはずです。
どのように実装するのが良いでしょうか。一つの案としては次のような実装があります。
import { Link, LinkProps } from "react-router-dom"
type ButtonProps =
| ({
isLink: true
external: true
} & React.ComponentPropsWithoutRef<"a">)
| ({
isLink: true
external: false
} & LinkProps)
| ({
isLink?: false
external?: undefined
} & React.ComponentPropsWithoutRef<"button">)
export const Button: React.FC<ButtonProps> = (props) => {
const { isLink, external } = props
if (isLink) {
if (external) {
const { isLink: _, external: __, children, ...rest } = props
return (
<a className="ui_button" {...rest}>
{children}
</a>
)
}
const { isLink: _, external: __, children, ..._rest } = props
return (
<Link className="ui_button" {..._rest}>
{children}
</Link>
)
}
const { isLink: _, external: __, children, ..._rest } = props
return (
<button className="ui_button" type="button" {..._rest}>
{children}
</button>
)
}
これは、ダブル判別可能なUnion型という感じの実装です。isLink
がaかbuttonかを判別するためのタグであると同時に、external
が外部リンク(a)か内部リンク(Link)かを判別するためのタグとなっています。
また、external?: undefined
という部分では、前記事「明日から使えるTypeScriptの応用テクニックその2 -判別可能なUnion/Distribution編-」でご紹介した存在しないプロパティをoptionalにするパターンを活用しています。
しかし、これはどう見てもスッキリした方法ではありません。
コンポーネントを分割してみる
そこでもう少し良い実装はないかと考えてみると、コンポーネントをButtonとButtonLinkに分割する手があるかもしれません。
type ButtonProps = Omit<React.ComponentPropsWithoutRef<"button">, "className">
export const Button: React.FC<ButtonProps> = (props) => {
const { children, ...rest } = props
return (
<button className="ui_button" type="button" {...rest}>
{children}
</button>
)
}
type ButtonLinkProps =
| ({
external: true
} & React.ComponentPropsWithoutRef<"a">)
| ({
external?: false
} & LinkProps)
export const ButtonLink: React.FC<ButtonLinkProps> = (props) => {
const { external } = props
if (external) {
const { external: _, children, ...rest } = props
return (
<a className="ui_button" {...rest}>
{children}
</a>
)
}
return (
<Link className="ui_button" {..._rest}>
{children}
</Link>
)
}
これは、さっきよりずっと見通しがいいコードのように思えます。これでもいいのですが、しかし、別のパターンも考えてみたいところです。
そこで登場するのが、ようやく今回の主題、Polymorphic Componentです。
Polymorphic Component
ポリモーフィックとはポリモーフィズムの形容詞の形で、「多形態の」と訳されるようです。ここではざっくり「色んな形をとれる」という意味で捉えてもらって大丈夫です。Polymorphic Componentは、色んな形を取れるコンポーネントです。
Chakra UI
やMui
、Mantine
といったモダンなUIコンポーネントライブラリを使ったことがある方は、実はすでにPolymorphic Componentを知っています。
Chakra UI
のas prop
やMui
のcomponent prop
に対応しているコンポーネントが、Polymorphic Componentです。
例えば、Chakra UIのBoxコンポーネントは、<Box as='button'>
とすればbuttonタグに、<Box as='a'>
とすればaタグにレンダリングされます。
Polymorphic Componentは、指定のHTMLタグにレンダリングされるだけではなく、完璧な型の補完が効きます。例えば、
export const Example = () => {
return (
<>
<Box as="a" href="" /> ← ✅ コンパイルエラーにならない
<Box as="a" value="" /> ← 🚨 コンパイルエラーになる
<Box as="button" href="" /> ← 🚨 コンパイルエラーになる
<Box as="button" value="" /> ← ✅ コンパイルエラーにならない
</>
)
}
というように、as
に何を指定したかによって指定可能な属性の型が変化し、無用なバグが発生する前にコンパイルエラーにしてくれます。このような手厚い型のサポートが効けば、多くの悲劇を未然に防げるはずです。
そんな魅力的なPolymorphic Componentですが、そんなに実装が難しいものではありません。次のように実装は意外とシンプルです。
type ButtonProps<TElementType extends React.ElementType> = Omit<
React.ComponentPropsWithoutRef<TElementType>,
"className" | "as"
> & {
as?: TElementType
}
export const Button = <TElementType extends React.ElementType>(
props: ButtonProps<TElementType>,
): React.ReactElement => {
const { as, children, ...rest } = props
const Component: React.ElementType = as ?? ("button" as React.ElementType)
return (
<Component className="ui_button" {...(as === "button" && { type: "button" })} {...rest}>
{children}
</Component>
)
}
ポイントは次の3つです。
- React.ElementType
- ジェネリクスを用いる
- コンポーネントは大文字ではじめなければならない
ここからは説明が長くなるので、説明が不要な方やお急ぎの方はdoneです!までスキップしてください。
1.React.ElementType
Reactの型定義ファイルをのぞいてみると、React.ElementType
は、次のようになっています。
type ElementType<P = any> =
| {
[K in keyof JSX.IntrinsicElements]: P extends JSX.IntrinsicElements[K] ? K : never
}[keyof JSX.IntrinsicElements]
| ComponentType<P>
これは、Mapped TypesとConditional Typesの合わせ技ですね。これを読み解くには、前記事の内容が役に立ちそうです。
ComponentType
を一旦無視して少し簡略化すると、[1]
type ElementType = {
[K in keyof JSX.IntrinsicElements]: K
}[keyof JSX.IntrinsicElements]
は、
{
a: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>,
button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
...
}["a" | "button" | "div" ...]
↓
"a" | "button" | "div"...
ということなので、
const aTag: React.ElementType = "a" ← ✅コンパイルエラーにならない
const buttonTag: React.ElementType = "button" ← ✅コンパイルエラーにならない
const divTag: React.ElementType = "div" ← ✅コンパイルエラーにならない
const hogeTag: React.ElementType = "hoge" ← 🚨コンパイルエラーになる
というように、HTMLタグの名前以外のstring
を許しません。
また、先ほど一旦無視したComponentType
ですが、型定義は、
type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>;
です。これは「クラスコンポーネントor関数コンポーネント」という型定義です。なので、
const Hoge: React.FC = () => <div/>
const HogeHoge: React.ElementType = Hoge // OK!
のように、React.FC
をReact.ElementType
に代入してもコンパイルエラーが起きません。React.FC
がReact.ElementType
の条件を満たしている部分型だからです。
以上をまとめると、React.ElementType
とは、「HTMLのタグの名前か、クラスor関数コンポーネント」を指す型定義ということになります。
2.ジェネリクスを用いる
React.FC
の型は非常に便利な型です。
const Hoge: React.FC<HogeProps>
という短い構文で、「HogeProps
型のpropsを受け取り、React.Element型かnullを返す関数である」という十分な型の情報を与えています。これはいわゆるcontextual typeというもので、Hoge
に指定された型の文脈から、引数の型HogeProps
を推論できる仕組みです。
そんな便利なReact.FC
型ですが、一つだけ問題があって、それは型を受け取ることができない(=ジェネリクスを使えない)ということです。
ジェネリクスを使いたいときは代わりに、
const Hoge = <T>(props: HogeProps<T>): React.ReactElement
と書く必要があります。
人によっては、↑の構文を見慣れない構文と感じるでしょうか?
しかし、これはハッキーなトリックなどではなく、いたって普通のTypeScriptです。
Reactには魔術的な構文がほとんどなく、すべてがJavaScriptとTypeScriptで説明がつきます。それがReactの最大の魅力のうちの一つですが、今回もその例に漏れません。
例えば、以下のような普通のTypeScriptの関数があったとします。
const identity<Type>(arg: Type): Type {
return arg;
}
この関数は、
const output = identity<string>("myString");
と、型を指定して呼び出すこともできれば、
const output = identity("myString");
というように型を省略して呼び出すこともできます。
この場合、引数の型とジェネリクスの型はどちらも型変数Typeとなっていて同じものです。そのため、コンパイラーは型推論を行うことができ、その結果、我々はわざわざ型を指定しなくても関数identify
を呼び出すことができるのです。
Reactの関数コンポーネントもこのジェネリクスの仕組みは全く相違ありません。
const Hoge = <T extends Record<string, string>>(props: T): React.ReactElement => {
return <div />
}
という関数コンポーネントは、
const HogeHoge = () => {
return <Hoge<{ foo: string }> foo={""} />
}
という呼び出し方もできますが、これではいかにも気持ち悪く、読みづらいJSXです。しかし、型推論の力を持ってすれば、
const HogeHoge = () => {
return <Hoge foo={""} />
}
というように、まるで普通のコンポーネントのように呼び出すことができます。
今回の場合、TElementType
の型を、React.ComponentPropsWithoutRef<TElementType>
としてReact.ComponentPropsWithoutRef
に渡しています。では、React.ComponentPropsWithoutRef
ですが、こいつは一体何者でしょうか。
Reactの型定義をのぞいてみると、おおよそ次のようになっています。(コメントは独自のもの)
type ComponentPropsWithoutRef<T extends ElementType> = PropsWithoutRef<ComponentProps<T>>;
// ↓ Distributive Omit
type PropsWithoutRef<P> = P extends any ? ('ref' extends keyof P ? Omit<P, 'ref'> : P) : P;
type ComponentProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> =
T extends JSXElementConstructor<infer P>
? P
: T extends keyof JSX.IntrinsicElements
? JSX.IntrinsicElements[T]
: {};
interface IntrinsicElements {
a: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
...
}
そのため、ComponentPropsWithoutRef<"a">
を指定した場合、最終的にはAnchorHTMLAttributes、つまり、
download?: any;
href?: string | undefined;
hrefLang?: string | undefined;
...
のような型が得られ、ComponentPropsWithoutRef<"button">
を指定した場合、最終的にはButtonHTMLAttributes、つまり
disabled?: boolean | undefined;
form?: string | undefined;
formAction?: string | undefined;
...
のような型が得られる仕組みです。
3.コンポーネントは大文字ではじめなければならない
1でもみたように、Reactの要素となれるものの型・React.ElementTypeは、HTMLのタグの名前かコンポーネントです。そして、コンポーネントには、必ず大文字で始めなければならないというルールがあります。
ということは、小文字から始まるタグ以外のものは、Reactの要素にはなれません。例えば、次のようなコードはもちろんエラーになります。
type ButtonProps = {
as?: TElementType
}
export const Button: React.FC<ButtonProps> = (props) => {
const { as, children, ...rest } = props
return (
+ <as className="ui_button"> ← 🚨HTMLのタグの名前でもコンポーネントでもないのでReactの要素になれない
{children}
</as>
)
}
したがって、as
をレンダリングするためには次のように、一度なんでもいいので大文字の変数においてあげる必要があります。
type ButtonProps = {
as?: TElementType
}
export const Button: React.FC<ButtonProps> = (props) => {
const { as, children, ...rest } = props
+ const Component: React.ElementType = as ?? ("button" as React.ElementType)
return (
+ <Component className="ui_button">
{children}
+ </Component>
)
}
Doneです!
いやぁ説明が長くなりすぎましたね。。
ということで、以上がPolymorphic Componentが動く仕組みです。では早速、その強力な型サポートの恩恵を確認してみたいと思います。
いい感じですね!
ちなみにrefを渡してあげたい場合は、React.ComponentPropsWithRef
を使って次のように型をつけてあげればOKです。
type ButtonComponent = <TElementType extends React.ElementType = "button">(
props: ButtonProps<TElementType> & { ref?: React.ComponentPropsWithRef<TElementType>["ref"] },
) => React.ReactElement
type ButtonProps<TElementType extends React.ElementType> = Omit<
React.ComponentPropsWithoutRef<TElementType>,
"className" | "as" | "ref"
> & {
as?: TElementType
}
export const Button = forwardRef(
<TElementType extends React.ElementType = "button">(
props: ButtonProps<TElementType>,
ref: React.ComponentPropsWithRef<TElementType>["ref"],
) => {
const { as, children, ...rest } = props
const Component: React.ElementType = as ?? ("button" as React.ElementType)
return (
<Component className="ui_button" {...(as === "button" && { type: "button" })} {...rest} ref={ref}>
{children}
</Component>
)
},
) as ButtonComponent
さらに詳しい解説を読みたい人は、Build strongly typed polymorphic components with React and TypeScriptをご覧ください。
あとがき:デザインシステムの実装について
今回は、Polymorphic Componentにフォーカスしてご紹介しましたが、それが唯一の正しい方法だと述べたかったわけではありません。ButtonとButtonLinkでコンポーネントを分割する方がより良い手段だという可能性もありえます。
このように、共通コンポーネント(デザインシステムの実装部分)のインターフェースをどうするかというのは実に悩ましい問題です。
自由度を上げれば上げるほど、デザインの一貫性は失われ、品質の担保も難しくなります。かといって、共通コンポーネントの自由度を下げすぎると、汎用性を欠いた、使い道のないコンポーネントになってしまいます。
できれば自分たちのユースケースに合った良い塩梅に調節したいものです。そんなとき、TypeScriptを上手に使うと、自由度を自在にコントロールすることができます。
例えば、今回は密かにOmit<React.ComponentPropsWithoutRef<"button">, "className">
として、classNameを指定できないようにしていました。しかし、これは、
import { clsx } from "clsx"
type ButtonProps = React.ComponentPropsWithoutRef<"button">
export const Button: React.FC<ButtonProps> = (props) => {
const { children, className, ...rest } = props
return (
<button className={clsx("ui_button", className)} type="button" {...rest}>
{children}
</button>
)
}
とした方がclassNameを使ってスタイルを変更するのが容易になり、自由度が上がります。
しかし、それが良いのか悪いのかは別の話です。
以前、日本三大デザインシステムについて雑に感想をまとめるという記事を書きましたが、この三大デザインシステムの中でも、classNameに渡せる実装とそうでない実装があります。やはり共通コンポーネントにclassNameを渡せるようにすべきか否かは、フロントエンドエンジニアの中でも意見が分かれる部分なんだと思います。
classNameを渡せるようにすると自由度は上がりますが、逆にもっと自由度を下げる方向で考えてみるなら、例えば、
+ type ButtonProps<TElementType extends "button" | "a" | React.FC<any>> =
Omit<React.ComponentPropsWithoutRef<TElementType>, "className"> & {as?: TElementType}
+ export const Button = <TElementType extends "button" | "a" | React.FC<any>>(
props: ButtonProps<TElementType>,
): React.ReactElement => {
const { as, children, ...rest } = props
const Component: React.ElementType = as ?? ("button" as React.ElementType)
return (
<Component className="ui_button" {...(as === "button" && { type: "button" })} {...rest}>
{children}
</Component>
)
}
のように、button
とa
以外のタグはas
に指定できないようにするという実装も考えられます。無限に選択肢があって迷ってしまうところです。
そんなときは「迷ったらとりあえずルールは厳しい方に寄せる」という方針もありかもしれません。デザインシステムにおいては、緩いルールを厳しくするよりも、厳しいルールを緩和する方がもおそらく簡単だからです。
要はバランスおじさんになってしまいますが、結局のところ、自分のチームにマッチするやり方が一番ですね。
おわり
ということで、長くなってしまいましたが三部にわたるTypeScriptの応用テクニックでした。
お読みいただきありがとうございました!
(筆者はTypeScript初心者なので、もし間違いがありましたらお優しめにご指摘ください🙏)
...いや、最後に少しだけ裏話をさせてください。
現在、実務でSemanticUIReactを使っているのですが、これがなかなか辛いライブラリです。
このライブラリでは、コンポーネントの型が次のように定義されています。
export interface ButtonProps extends StrictButtonProps {
[key: string]: any
}
今回の記事を読んでいただいた方には、この型のヤバさが十分に伝わると思います。
<Button hrefo onClicko={"yeah"} yeaaaahhhhh/>
などと好き放題書いても全然コンパイルエラーになりません。
型だけではなく、ここでは語りきれない悲々交々の辛みがあります。もちろんライブラリの作成者の方には感謝と尊敬の想いですが、敢えて言葉を選ばずに言えば、今は役目を終えてレガシーなライブラリとなってしまったと思います。
そこで我々は、プロジェクト横断的にデザインシステムを定義し、共通コンポーネントを実装し始めました。リポジトリはTurboRepoのモノレポで、スタイルはPandaCSS、ロジック部分はArkを使っています。
SemanticUIReactに辛みを感じながらも、気づいたらなんやかんやで1年半過ぎて、ようやく、ようやくここまで辿り着きました...。もう少し、もう少しの力があれば、このプロジェクトを加速させることができるのに...。
ということで、今回の話にご興味がある方、何卒、DMお待ちしております🙏
-
Pの型引数に何も指定しなかった場合、デフォルト値のanyが採用されて、
any extends JSX.IntrinsicElements[K] ? K : never
となります。このとき、その1の脚注のところで書いたように、K | never
となるのですが、K | never
は要するにK
です。 ↩︎
Discussion