TypeScriptの名前空間を活用した
UIコンポーネントの設計と
型安全性の追求

TSKaigi Kansai@みやこめっせ京都
saku🌸 / @sakupi01

saku

Web Frontend Engineer
@Cybozu

🍏/ ☕/ 🌏 > ❤️
𝕏 @sakupi01

TypeScriptの名前空間を活用した
UIコンポーネントの設計と
型安全性の追求

Available in EN

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) { ... }

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

Props名がやたら冗長になる

なぜ?

  1. 命名によってデータの性質・構造を表現したい
    1. 例えば、EnhancedNavigationButtonPropsという名前はEnhancedなNavigationのButtonコンポーネントのPropsであることを表現している
  2. 型が値に関するものであることを表現したい
    1. 1と関連するが、ButtonPropsという名前はButtonコンポーネントのPropsであることを表現している
  3. 1, 2を名前の衝突を防ぎつつ行いたい

→ コンポーネント名を冠したProps名をつける

命名による解決策の限界

  • Propsの型を変更するのに合わせてコンポーネント名を変更する必要
    → 保守性の低下
  • 関連するデータが肥大化・多くの子コンポーネントを包含したコンポーネント
    → 命名が複雑化

命名の工夫以外で、

  1. コンポーネントという 「値」とそのPropsである「型」をセットで管理しつつ
  2. ネストされたデータの構造を表現したい

1. 値と型をセットで管理する

結論:TypeScriptのコンパニオンオブジェクトパターンを活用する

TypeScriptの利点: 値と型を別のものとして扱うことができる

  • 例えば、もともとはJavaScriptで記述されたライブラリに後から型定義を追加することができる

→ 逆を言うと、コンポーネントのように値と型の関連を保ちたい場合は、意識的に保守していく必要

コンパニオンオブジェクトパターン

  • 値と型は別物
  • →値と型を同名で定義しても問題ない
// 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である「型」をセットで管理できた!

2. ネストされたデータ構造を表現する

結論:TypeScriptのnamespaceを活用する

TypeScriptの値と型

  • 「値」:JavaScriptのコードに残って「ランタイムに影響する要素」
  • 「型」:トランスパイル時に削除されて「ランタイムに影響しない要素」
  • TypeScriptでは「値」と「型」のモジュール化方法が異なる

「値」のモジュール化

  • オブジェクトの使用
  • 即時実行関数式(IIFE)によるスコープ制限を使用

「型」のモジュール化

  1. ESMのimport asexport as
  2. TSのnamespace

NavigationButton.Propsを表現する

1. ESMのimport asexport 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) { ... }

UIコンポーネントのProps型でESMによる構造化をするのが微妙な点

  • 名前空間作成のためにファイルを分割する必要がある
    • ネストされた型オブジェクトとして扱いたい場合、re-exportを重ねる必要がある
      →単にデータ構造を表現するためだけにそこまでファイル分割をしたくない
  • import asで利用側で自由に名前をつけて import できてしまう
    →一貫性が損なわれてしまう恐れ

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内には他の型定義をすることもできる

コンポーネントに関連する型定義を一箇所にまとめる

// 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) { ... }

3. まとめ

TypeScriptのnamespace(と、コンパニオンオブジェクトパターン)を活用することで、

  • 値と型を一体化して管理しつつ、データ型の構造をモジュールで表現できた
    →柔軟で構造的な命名が可能に!

UIコンポーネントの設計のtipsとして頭の片隅に置いておくと良さそう

ご清聴ありがとうございました!

みなさんは、tsxでUIコンポーネントを作成する際に、どのように実装していますか?

私も含め、ほとんどの人が以下のようにButtonコンポーネントとそれに関連するButtonPropsというふうに定義していると思います

そして、その拡張であるNavigationButtonコンポーネントとそれに関連する型となると以下のように定義していると思います。

しかしこのEnhancedNavigationButtonのように、拡張を重ねていくとProps名がやたら冗長になることがあります

このようにコンポーネント名を冠したPropsの命名には限界があります

コンパニオンオブジェクトパターンを知る前に、 TypeScriptは値と型を別物として扱うということを再確認したいです

コンパニオンオブジェクトパターンでは、値と型が別物ということを生かし、値と型を同名で定義することでセットで扱うことができるというものです

このように、コンポーネントとPropsをどちらもButtonとして定義することで、セットで管理することができる。 このように、Buttonとするだけで型の意味でも値の意味でもButtonとして扱うことができる

まず、本題に入る前に、TypeScriptの値と型について定義したいと思います

これらを踏まえて、`NavigationButton.Props`を表現してみます

まず、ESMの`import as`・`export as`を使うと、このようにimportのタイミングで名前空間オブジェクトを生成して、型をモジュール化することができます。

しかし、ESMの`import as`・`export as`を使うと微妙な点がいくつかあります

そこで、namespaceを使って型をモジュール化してみると、以下のようにnavigation-button.tsxで命名せずにすみ、かつ、re-exportのためのファイルを経由することなくNavigationButton.Propsと表現することができます。

--- ### TSの`namespace`を用いてデータ型の構造をモジュールで表現する - 利用側は定義された名前空間オブジェクト名をそのまま使用できる - **命名の一貫性を保つことができる** - 同一ファイル内で型をモジュール化 - **re-exportのためのファイルが不要**

さらに、UIコンポーネントの設計においてTypeScriptのnamespaceを活用することで、コンポーネントに関連する型定義を一箇所にまとめることができたりもします