TypeScriptの名前空間を活用した
UIコンポーネントの設計と
型安全性の追求
TSKaigi Kansai@みやこめっせ京都
saku🌸 / @sakupi01
saku
Design Technologist / ex-Web Frontend @Cybozu
Goolgle Developer Expert for Web Technologies
🍏 / ☕ / 🌏 >
TypeScriptの名前空間を活用した
UIコンポーネントの設計と
型安全性の追求
TSXでUIコンポーネントを作成する
// components/button.tsx
export type ButtonProps = { ... };
export function Button(props: ButtonProps) { ... }
// components/navigation-button.tsx
import { Button, type ButtonProps } from "./button";
export type NavigationButtonProps = ButtonProps & { ... };
export function NavigationButton(props: NavigationButtonProps) { ... }
私も含め、ほとんどの人が以下のようにButtonコンポーネントとそれに関連するButtonPropsというふうに定義していると思います
そして、その拡張であるNavigationButtonコンポーネントとそれに関連する型となると以下のように定義していると思います。
TSXでUIコンポーネントを作成する
// components/enhanced-navigation-button.tsx
import { NavigationButton, type NavigationButtonProps } from "./navigation-button";
export type EnhancedNavigationButtonProps = NavigationButtonProps & { ... };
export function EnhancedNavigationButton(props: EnhancedNavigationButtonProps) { ... }
EnhancedNavigationButtonProps: EnhancedなNavigationのButtonのProps
しかしこのEnhancedNavigationButtonのように、拡張を重ねていくとProps名がやたら冗長になることがあります
Props名がやたら冗長になる
なぜ?
- 命名によってデータの性質・構造を表現したい
- 例えば、
EnhancedNavigationButtonPropsという名前はEnhancedなNavigationのButtonコンポーネントのPropsであることを表現している
- 例えば、
- 型が値に関するものであることを表現したい
- 1と関連するが、
ButtonPropsという名前はButtonコンポーネントのPropsであることを表現している
- 1と関連するが、
- 1, 2を名前の衝突を防ぎつつ行いたい
→ コンポーネント名を冠したProps名をつける
命名による解決策の限界
- Propsの型を変更するのに合わせてコンポーネント名を変更する必要
→ 保守性の低下 - 関連するデータが肥大化・多くの子コンポーネントを包含したコンポーネント
→ 命名が複雑化
このようにコンポーネント名を冠したPropsの命名には限界があります
命名の工夫以外で、
- コンポーネントという 「値」とそのPropsである「型」をセットで管理しつつ
- ネストされたデータの構造を表現したい
1. 値と型をセットで管理する
結論:TypeScriptのコンパニオンオブジェクトパターンを活用する
TypeScriptの利点: 値と型を別のものとして扱うことができる
- 例えば、もともとはJavaScriptで記述されたライブラリに後から型定義を追加することができる
- DefinitelyTypedはその良い例
→ 逆を言うと、コンポーネントのように値と型の関連を保ちたい場合は、意識的に保守していく必要
コンパニオンオブジェクトパターンを知る前に、 TypeScriptは値と型を別物として扱うということを再確認したいです
コンパニオンオブジェクトパターン
- 値と型は別物
- →値と型を同名で定義しても問題ない
// components/button/index.tsx
export type Button = { ... };
export function Button(props: Button) { ... }
コンパニオンオブジェクトパターンでは、値と型が別物ということを生かし、値と型を同名で定義することでセットで扱うことができるというものです
コンパニオンオブジェクトパターン
型と値を同名で定義することですることでセットで扱える
// components/button/index.tsx
export type Button = { ... };
export function Button(props: Button) { ... }
// components/navigation-button/index.tsx
import { Button } from "./button";
// Button Props(型)として
export type Props = Button & { ... };
export function NavigationButton(props: Props) {
// Buttonコンポーネント(値)として
return <Button {...props} className={{ ... }} />;
}
このように、コンポーネントとPropsをどちらもButtonとして定義することで、セットで管理することができる。 このように、Buttonとするだけで型の意味でも値の意味でもButtonとして扱うことができる
コンポーネントという「値」とそのPropsである「型」をセットで管理できた!
2. ネストされたデータ構造を表現する
結論:TypeScriptのnamespaceを活用する
TypeScriptの値と型
- 「値」:JavaScriptのコードに残って「ランタイムに影響する要素」
- 「型」:トランスパイル時に削除されて「ランタイムに影響しない要素」
- TypeScriptでは「値」と「型」のモジュール化方法が異なる
まず、本題に入る前に、TypeScriptの値と型について定義したいと思います
「値」のモジュール化
- オブジェクトの使用
- 即時実行関数式(IIFE)によるスコープ制限を使用
「型」のモジュール化
- ESMの
import as・export as - TSの
namespace
NavigationButton.Propsを表現する
これらを踏まえて、`NavigationButton.Props`を表現してみます
1. ESMのimport as・export as
/**
* components/button/type.tsx
*/
export type Props = {...};
/**
* components/navigation-button/type.tsx
*/
// モジュール名前空間オブジェクト(Button)としてインポート
import * as Button from './type';
export type Props = Button.Props & {...};
/**
* components/navigation-button/index.tsx
*/
// モジュール名前空間オブジェクト(NavigationButton)としてインポート
import * as NavigationButton from './type';
export function NavigationButton(props: NavigationButton.Props) { ... }
まず、ESMの`import as`・`export as`を使うと、このようにimportのタイミングで名前空間オブジェクトを生成して、型をモジュール化することができます。
UIコンポーネントのProps型でESMによる構造化をするのが微妙な点
- 名前空間作成のためにファイルを分割する必要がある
- ネストされた型オブジェクトとして扱いたい場合、re-exportを重ねる必要がある
→単にデータ構造を表現するためだけにそこまでファイル分割をしたくない
- ネストされた型オブジェクトとして扱いたい場合、re-exportを重ねる必要がある
import asで利用側で自由に名前をつけて import できてしまう
→一貫性が損なわれてしまう恐れ
しかし、ESMの`import as`・`export as`を使うと微妙な点がいくつかあります
TSのnamespaceを用いてデータ型の構造をモジュールで表現する
// components/button/index.tsx
export namespace Button {
export type Props = { ... };
}
export function Button(props: Button.Props) { ... }
/**
* components/navigation-button/index.tsx
*/
// - 利用側は定義された名前空間オブジェクト名をそのまま使用できる
// - **命名の一貫性を保つことができる**
import { Button } from "./button";
// - 同一ファイル内で型をモジュール化
// - **re-exportのためのファイルが不要**
export namespace NavigationButton {
export type Props = Button.Props & { ... };
}
export function NavigationButton(props: NavigationButton.Props) { ... }
そこで、namespaceを使って型をモジュール化してみると、以下のようにnavigation-button.tsxで命名せずにすみ、かつ、re-exportのためのファイルを経由することなくNavigationButton.Propsと表現することができます。
--- ### TSの`namespace`を用いてデータ型の構造をモジュールで表現する - 利用側は定義された名前空間オブジェクト名をそのまま使用できる - **命名の一貫性を保つことができる** - 同一ファイル内で型をモジュール化 - **re-exportのためのファイルが不要**
namespace内には他の型定義をすることもできる
コンポーネントに関連する型定義を一箇所にまとめる
// components/button.tsx
export namespace Button {
export type Variant = "solid" | "ghost" | "outline";
export type Size = "xs" | "sm" | "md" | "lg" | "xl";
export type Props = {
variant: Variant;
size: Size;
}
}
export function Button(props: Button.Props) { ... }
さらに、UIコンポーネントの設計においてTypeScriptのnamespaceを活用することで、コンポーネントに関連する型定義を一箇所にまとめることができたりもします
3. まとめ
TypeScriptのnamespace(と、コンパニオンオブジェクトパターン)を活用することで、
- 値と型を一体化して管理しつつ、データ型の構造をモジュールで表現できた
→柔軟で構造的な命名が可能に!
UIコンポーネントの設計のtipsとして頭の片隅に置いておくと良さそう
みなさんは、tsxでUIコンポーネントを作成する際に、どのように実装していますか?