Symbolを使った利用範囲が限定されたプロパティの実装

この記事は[株式会社カオナビ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kaonavi)の9日目の記事です。

# はじめに
自社で開発しているプロダクトデザインシステム「sugao」の開発中に私が遭遇した課題と、Symbolを用いた解決の経緯をまとめてみました。

https://brand.kaonavi.jp/sugao

## 課題
自社開発のプロダクトデザインシステムである`sugao`に、新しいコンポーネントを追加したいという要望があり、私が担当することになりました。このコンポーネントの開発にはすでに`Sugao`に実装されているコンポーネント`Button`を利用するのですが、この`Button`にも新しいデザインパターンを追加することになりました。

`Button`は以下のような形で利用することができます。kindという必須プロパティに種別を指定することでボタンのデザインが変わる、とざっくり考えてもらえれば大丈夫です。
```jsx
import { Button } from 'sugao';
// kindに特定の種別を指定することでデザインの切り替えが可能
<Button kind="normal">text</Button>
```

### sugao内(モジュール内)でのみ利用可能なデザインの追加
このボタンの改修だが、以下のルールが科されることになりました。

* 新コンポーネントで利用する`Button`のデザインとしてSugao内でのみ利用可能で、sugao外からの利用は不可とする
* sugao外での利用の制限はデザインルール/コーディングルールのどちらの面から制限してもかまわない


## 自分が`Symbolで実装`まで辿り着くまでの変遷
この課題に対して当初私が考えていたアプローチは以下の三つになります。

1\. 既存のButtonコンポーネントを丸々コピーした、外部にexportしない`SpecialButton`的なコンポーネントを新たにsugao内に追加し、デザインを書き換えて利用する
2\. `Button`のkindプロパティに指定可能なパラメータを追加する
3\. 特別なデザインパターンを利用することを示すフラグプロパティを`Button`に追加する


まず、案1. はデザイン分岐以外のロジックが全く同じコンポーネントがsugao内に複製されることで、`Button`というコンポーネントの動作仕様が変わるたびに二つのコンポーネントを修正しなければならないという保守性の悪さから不採用としました。

案2. については最も変更が容易ですが、kindは必須パラメータでよく設定されるものであり、そこに利用可能なパラメータを追加しておいて `使えるけど、使わないでね`的なルールを設定しても、ルールの絶対尊守は難しいと考えたため、検討段階でいったん不採用としています。(もちろん、コードレビューなどで弾くなどの対応はとれますが)

```jsx
ButtonKind = "normal" | ... | "specialDesign"; // specialはデザインシステム外では利用しないこと！

// 指定はできるから、コードレビュー等で弾く
<Button kind="specialDesign">text</Button>
```

残った案3. については、新デザインを利用するにはフラグの指定が必要なため、実装時に不用意に利用されることはないだろうという考えから、いい案なのではと最初は思っていました。

```jsx
<Button useSpecialDesign>text</Button> 
```
が、結局この案もあまりイケてる案ではないねという気持ちになり、アプローチを再考することになります。

### `デザインを指定する`プロパティが二つになる

`Button`には元々デザインを指定するために `kind`という必須プロパティがすでに設定されています。案3.ではここにさらに `useSpecialDesign`のような、特別なデザインを使いますよ、という意味あいのフラグを追加して利用することになる想定です。

そのため、`Button`を特別なデザインで使いたい場合、実際には以下のコードを書く必要があります。
```jsx
<Button kind="normal" useSpecialDesign>text</Button>
```
`kind`でデザインを指定しているのに、さらにそれをuseSpecialDesignのフラグで上書きする形になってしまっています。新デザインのButtonを使いたい場合、kindの指定は完全に無駄なものになります。

ならば排他的な型制御をいれて、`kind`か`useSpecialDesign` のどちらかのみを指定可能にしよう！と思ったものの、`Button`の実装内ではすでにkindに応じたデザインの切り替えロジックが点在しているため、さらにここに `useSpecialDesign`によるデザイン切り替えの分岐まで増やすことになると、これもまた煩雑になってしまうなぁという気持ちに。

じゃあどうすればよいだろうとなってしまい、他のエンジニア諸兄に相談したところ、`Symbol`を使って実装してはどうか？というアドバイスをもらうことができました。

# JavaScriptのSymbol is 何？
Symbolとはなんぞや？ということでこの辺りのドキュメントを読んだり、先輩エンジニアに相談したりしました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol

https://ja.javascript.info/symbol#ref-2208

今回の課題はSymbolの以下の点を押さえておくだけで解決できます
* SymbolはJavaScriptの組み込み関数である
* Symbolコンストラクターを利用することで、一意であることが保証されているシンボル値を取得することができる

気になるのは`一意であることが保証されているシンボル値を返す`、とはどういうことか？という点ですが、これはmozillaに掲載されている例がわかりやすいでしょう。

```js
// この三つはすべて違うシンボル値を返す
const sym1 = Symbol();
const sym2 = Symbol("foo");
const sym3 = Symbol("foo");

// コンストラクターに同じキーを指定しても、生成されるシンボル値は異なる
Symbol("foo") === Symbol("foo"); // false
```
Symbolコンストラクターには文字列をキーとして渡して値を生成することもできますが、やはり生成される値は一意であることが保証されています。ランタイム上でまったく同一のシンボル値を利用するためには、そのシンボル値を直接参照するなにかしらの手段を取る必要があります(わかりやすい手段としては定義したシンボル値をexport/importして利用することでしょう)

:::note warn
もし反対に、あなたがランタイム上のあらゆる箇所からある特定キー名に紐づくSymbol値を使いたいのであれば、[Symbol.for('キー名')](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol/for)を使うことでキー名に対応するシンボル値を生成し、あらゆる箇所で引き出して利用することができます。
:::

## Symbolを利用した特定のプロパティの利用制限
### kindに指定可能なパラメータとしてシンボル値を利用する
Symbol()によって生成されるシンボル値がランタイム上で一意になることはわかりました。あとはこれをどう課題に利用するかという話ですが、今回は`Button`コンポーネントの定義内に`useSpecialDesign`と同じ意味を持つシンボル値を定義して利用することにしました。

```jsx
// sugao内のButton定義
const specialDesignSymbol = Symbol("specialDesign");

// kindとして指定可能なものの中にsymbol値を混ぜる
type ButtonKind = 'normal' | ... | typeof specialDesignSymbol;  

// Buttonを使いたいどこか
<Button kind={specialDesignSymbol}>text</Button>
```
`kind`に指定可能なパターンが増えているため、すでにあるkindに応じたデザイン切り替えのロジックも修正します。ざっくりとしたイメージはこう
```jsx
// 修正前
const getButtonColor = (kind: ButtonKind): Color => {
  switch (kind){
    case 'normal':
      return 'green';
    case 'warning':
      return 'red';
    ...;
  }
}

// 修正後
const getButtonColor = (kind: ButtonKind): Color => {
  switch (kind){
    case 'normal':
      return 'green';
    case 'warning':
      return 'red';
    ...,
    // 追加
    case specialDesignSymbol:
      return 'specialColor';
  }
}
```
これで`Button`に対して`kind: specialDesignSymbol`に対応した実装ができました。

このままでは 案2. のkindに種別をただ追加するパターンと同じではないのか？と思われるかもしれません。しかしそこは心配無用です。なぜなら`specialDesingSymbol`は`Symbol()`によって生成されたシンボル値だから。

### シンボル値のexport範囲を限定し、外部からの利用を制限する
`kind`に指定できる`specialDesignSymbol`は`Button`に定義されている`specialDesignSymbol`というシンボル値を指しています。外部から同じ値を持つシンボル値を生成することはできません。したがって、新デザインを`Button`を利用するには、sugaoの`Button`定義から`specialDesignSymbol`もimportして利用する必要があります。
```jsx
// sugao/Button

// シンボル値そのものをexportして、Button以外のSugaoのコンポーネント定義からもimport可能にする
export const specialDesignSymbol = Symbol('specialDesignSymbol');
```

つまり`speicialDesignSymbol`をSugaoというデザインシステムのモジュールの外にexportさえしなければ、Sugao外のプロダクトからは利用できないという制限がかけられるのです。

```jsx
// sugaoのmodule exportリスト
// ここでexport宣言されていないものはsugaoのモジュールからexportされない
...,
export {
  Button,
  type ButtonKind,
  ...,
  // specialDesignSymbol, //このシンボル値はsugaoモジュールの外にexportしない
} from './Button';
```

```jsx
// sugaoを使いたい外部プロダクト

// specialDesignSymbolはsugao外にexportしていないのでimportできない
import { Button } from 'sugao'; 

// 同じキー名でSymbol()を利用しても、返されるシンボル値はランタイム上で必ず一意であるため、
// sugao/Button内に定義されているspecialDesignSymbolとは別のシンボル値となる
const useSpecialDesign = Symbol('specialDesignSymbol'); 

// シンボル値が異なるためkindに指定できない
// Type 'unique symbol' is not assignable to type 'ButtonKind'.ts(2322)
<Button kind={useSpecialDesign}>text</Button>  
```

もちろんこのシンボル値がモジュール外からも参照可能な状態になってしまうと元も子もないため、コメントなどは付け加えておくのがよいでしょう
```jsx
// ugao内でのみ利用するため、モジュール外へのexport禁止
const specialDesignSymbol = Symbol('specialDesignSymbol');
```

## おわりに
実ははじめSymbolをつかってみてはどうか？とアドバイスいただいた時は「一意な値を返すって、それいったいなんの旨味があるんだ？」と悩んだりもしたのですが、先輩エンジニアのサポートもあり無事課題解決まで辿り着くことができました。(ちょっとしたAHA体験でした)

今回紹介した課題はあまり頻繁に遭遇するものではないかもしれませんが、もし同じ課題に悩んでいる方、あるいは`Symbol`について学びはじめた方にとって、この記事が解決の一助となってくれればうれしいです。