10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

株式会社カオナビAdvent Calendar 2024

Day 9

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

Last updated at Posted at 2024-12-24

この記事は株式会社カオナビ Advent Calendar 2024の9日目の記事です。

はじめに

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

課題

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

Buttonは以下のような形で利用することができます。kindという必須プロパティに種別を指定することでボタンのデザインが変わる、とざっくり考えてもらえれば大丈夫です。

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

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

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

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

<Button useSpecialDesign>text</Button> 

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

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

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

そのため、Buttonを特別なデザインで使いたい場合、実際には以下のコードを書く必要があります。

<Button kind="normal" useSpecialDesign>text</Button>

kindでデザインを指定しているのに、さらにそれをuseSpecialDesignのフラグで上書きする形になってしまっています。新デザインのButtonを使いたい場合、kindの指定は完全に無駄なものになります。

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

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

JavaScriptのSymbol is 何?

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

今回の課題はSymbolの以下の点を押さえておくだけで解決できます

  • SymbolはJavaScriptの組み込み関数である
  • Symbolコンストラクターを利用することで、一意であることが保証されているシンボル値を取得することができる

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

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

// コンストラクターに同じキーを指定しても、生成されるシンボル値は異なる
Symbol("foo") === Symbol("foo"); // false

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

もし反対に、あなたがランタイム上のあらゆる箇所からある特定キー名に紐づくSymbol値を使いたいのであれば、Symbol.for('キー名')を使うことでキー名に対応するシンボル値を生成し、あらゆる箇所で引き出して利用することができます。

Symbolを利用した特定のプロパティの利用制限

kindに指定可能なパラメータとしてシンボル値を利用する

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

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

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

// Buttonを使いたいどこか
<Button kind={specialDesignSymbol}>text</Button>

kindに指定可能なパターンが増えているため、すでにあるkindに応じたデザイン切り替えのロジックも修正します。ざっくりとしたイメージはこう

// 修正前
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に種別をただ追加するパターンと同じではないのか?と思われるかもしれません。しかしそこは心配無用です。なぜならspecialDesingSymbolSymbol()によって生成されたシンボル値だから。

シンボル値のexport範囲を限定し、外部からの利用を制限する

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

// sugao/Button

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

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

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

もちろんこのシンボル値がモジュール外からも参照可能な状態になってしまうと元も子もないため、コメントなどは付け加えておくのがよいでしょう

// ugao内でのみ利用するため、モジュール外へのexport禁止
const specialDesignSymbol = Symbol('specialDesignSymbol');

おわりに

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

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

10
2
0

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
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?