🔰

TypescriptとSymbolで関数のラップを強制する

2024/09/28に公開2

開発の際に、ログ出力やパフォーマンス計測、エラーハンドリングなどを行うために、「必ず特定の関数をラップしてから使用する」というようなルールを設けることがあると思います。しかし、こうしたラッピングを忘れてしまったり、意図せずラップされていない関数を使用してしまうことがしばしばあります。こうした漏れが発生すると、デバッグやトラブルシューティングが困難になったり、必要なデータが収集できず、データ分析に基づいた意思決定ができなくなることが想定されます。

今回は、TypeScript と Symbol を使って、このようなミスを防ぎ、特定の関数ラップを強制的に適用させる方法を紹介します。例として、onClick イベント時に必ずログを出力する仕組みを作ることで、ログの取り忘れを防ぐ方法を見ていきましょう。

Symbol とは?

Symbol は、JavaScript で用意されているプリミティブ型の一つで、主にオブジェクトのプロパティキーとして使われます。Symbol がもつ特徴的な性質は、一意であることです。どの Symbol もそれぞれ異なるため、衝突しない一意なプロパティを作ることができます。

const sym1 = Symbol('description');
const sym2 = Symbol('description');

console.log(sym1 === sym2); // false

このように、同じ説明(description)を持っていても、sym1 と sym2 は全く異なるSymbol です。この特性を活かして、オブジェクトに特定の Symbol を付与し、その Symbol が存在するかどうかで状態やルールを判定することができます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol

onClick イベント時にログを出力する

普通に実装する

まずは、onClick イベントに対してログを出力するだけのシンプルな実装方法を見てみましょう。この方法では、onClick イベントハンドラー内でログ出力を行います。

// app/page.tsx
'use client';

import { Button } from '@/app/components/Buttton';
import { MouseEventHandler, useState } from 'react';

export default function Home() {
  const [count, setCount] = useState(0);
  const onClickButton: MouseEventHandler<HTMLButtonElement> = () => {
    setCount(count + 1);
    console.log('Button clicked'); // 手動でログを出力する
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2 gap-4">
      <p className="text-2xl font-bold">Count: {count}</p>
      <div className="flex gap-4">
        <Button onClick={onClickButton}>Normal Button</Button>
      </div>
    </div>
  );
}
// app/components/Button.tsx
import { ComponentPropsWithoutRef } from 'react';

type Props = ComponentPropsWithoutRef<'button'>;

export function Button({ children, ...buttonProps }: Props) {
  return (
    <button
      className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
      {...buttonProps}
    >
      {children}
    </button>
  );
}

この実装では、onClickButton 関数の中で、ボタンがクリックされたときにカウントを増加させると同時に console.log('Button clicked'); でログを出力しています。シンプルで直感的ですが、この方法では、特にログを記述しなくても型エラーなどが起きないため、ログ出力処理を記述し忘れるという問題が発生することが予想されます。
ログ出力のための関数でラップすることで、多少記述忘れを防ぐことができるかもしれませんが、ラップしなくても特にエラーが起きないため、根本的な解決にはなっていません。

Symbol を用いてログ出力を強制する

次に、Symbol を用いてログ出力を強制する方法を見ていきましょう。この方法では、Symbol を使って、ログ出力を行うラップ関数を型レベルで強制し、通常の onClick ハンドラーではエラーとなるようにします。

1. ログ出力用のラップ関数を作成する

まず、onClick ハンドラーに対してログ出力を追加するために、ラップ関数を作成します。このラップ関数は、もとの onClick ハンドラーとログを出力する関数を受け取り、それらをまとめた新しい関数を返す役割を果たします。さらに、この新しい関数に「この関数はラップされている」ということを示す証拠として Symbol を付与します。

// app/utils/clickLog.ts
import { MouseEventHandler } from 'react';

// ラップ済みの関数を識別するための一意のシンボルを定義
const clickLogSymbol = Symbol('CLICK_LOG');

// ラップされた関数に付与する型定義
export type OnClickWithLog = MouseEventHandler & {
  [clickLogSymbol]: true;
};

// イベントハンドラーとログ関数を受け取り、それらをラップした新しい関数を返す
export function onClickWithLog(
  eventHandler: MouseEventHandler,
  logger: () => void
): OnClickWithLog {
  // 受け取ったイベントハンドラーとログ関数をまとめた新しい関数を作成
  const onClickWithLog: OnClickWithLog = (event) => {
    eventHandler(event); // 元のイベントハンドラーを実行
    logger(); // 追加のログ関数を実行
  };

  // ラップされた関数であることを示すシンボルを付与
  onClickWithLog[clickLogSymbol] = true;

  return onClickWithLog;
}

ここで、clickLogSymbol は「この関数はログ出力用にラップされている」ということを識別するための Symbol です。シンボルは一意であるため、このプロパティをもつ関数は他のどの関数とも混同されることがありません。また、このラップ関数が返すのはMouseEventHandler 型に加えて clickLogSymbol という特別なプロパティを持った新しい型の関数です。これによって、Symbolをもつことが条件となるコンポーネントの使用を制限することが可能になります。

2. ラップされた関数のみを受け付けるコンポーネントを作成する

次に、このラップ済み関数のみを受け付ける ButtonWithLog というコンポーネントを作成します。このコンポーネントは、onClick プロパティに対して通常の onClick ハンドラーを渡した場合にエラーが発生するようになっています。

// app/components/ButtonWithLog.tsx
import { OnClickWithLog } from '@/app/utils/clickLog';
import { ComponentPropsWithoutRef } from 'react';

// 通常のボタンのプロパティから 'onClick' プロパティを除外し、代わりにOnClickWithLog型を要求
type Props = Omit<ComponentPropsWithoutRef<'button'>, 'onClick'> & {
  onClick: OnClickWithLog;
};

export function ButtonWithLog({ children, ...buttonProps }: Props) {
  return (
    <button
      className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
      {...buttonProps}
    >
      {children}
    </button>
  );
}

ここで、Props 型の定義を見ると、通常の button 要素のプロパティから onClick プロパティを除外し、代わりに OnClickWithLog 型を求めています。OnClickWithLog 型は、MouseEventHandler 型に加えて clickLogSymbol プロパティをもつ型として定義されています。つまり、ButtonWithLog コンポーネントに渡される onClick ハンドラーは、この特別なシンボルを持っていなければならないという制約がかかっています。

通常の onClick ハンドラーはこのシンボルを持たないため、ButtonWithLog に渡そうとすると、型の不一致が原因でエラーが発生します。これにより、「ログ出力を含まない通常のハンドラーを使うことはできない」という制約を型レベルで強制できます。

これにより、ButtonWithLog に通常の onClick ハンドラーを入れると型エラーになります。

'use client';

import { ButtonWithLog } from '@/app/components/ButtonWithLog';
import { Button } from '@/app/components/Buttton';
import { MouseEventHandler, useState } from 'react';

export default function Home() {
  const [count, setCount] = useState(0);
  const onClickButton: MouseEventHandler<HTMLButtonElement> = () => {
    setCount(count + 1);
    console.log('Button clicked'); // 手動でログを出力する
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2 gap-4">
      <p className="text-2xl font-bold">Count: {count}</p>
      <div className="flex gap-4">
        <Button onClick={onClickButton}>Normal Button</Button>
        {/*
        型エラー
        型 'MouseEventHandler<HTMLButtonElement>' を型 'OnClickWithLog' に割り当てることはできません。
        型 'MouseEventHandler<HTMLButtonElement>' を型 '{ [clickLogSymbol]: true; }' に割り当てることはできません。
         */}
        <ButtonWithLog onClick={onClickButton}>Button With Log</ButtonWithLog>
      </div>
    </div>
  );
}

型エラーを回避するためには onClickWithLog 関数でラップする必要があり、結果としてログ出力を強制することができます。

'use client';

import { ButtonWithLog } from '@/app/components/ButtonWithLog';
import { Button } from '@/app/components/Buttton';
import { onClickWithLog } from '@/app/utils/clickLog';
import { MouseEventHandler, useState } from 'react';

export default function Home() {
  const [count, setCount] = useState(0);
  const onClickButton: MouseEventHandler<HTMLButtonElement> = () => {
    setCount(count + 1);
    console.log('Button clicked'); // 手動でログを出力する
  };

  const onClickButtonWithLog = onClickWithLog(
    () => {
      setCount(count + 1);
    },
    () => {
      console.log('ButtonWithLog clicked');
    }
  );

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2 gap-4">
      <p className="text-2xl font-bold">Count: {count}</p>
      <div className="flex gap-4">
        <Button onClick={onClickButton}>Normal Button</Button>
        {/*
        型エラー
        型 'MouseEventHandler<HTMLButtonElement>' を型 'OnClickWithLog' に割り当てることはできません。
        型 'MouseEventHandler<HTMLButtonElement>' を型 '{ [clickLogSymbol]: true; }' に割り当てることはできません。
         */}
        {/* <ButtonWithLog onClick={onClickButton}>Button with Log</ButtonWithLog> */}
        {/* onClickWithLog 関数でラップした関数を使用することにより、型エラーを回避できる */}
        <ButtonWithLog onClick={onClickButtonWithLog}>
          Button With Log
        </ButtonWithLog>
      </div>
    </div>
  );
}

このように、Symbol を用いることで、開発時に必ず実行したい処理を強制的にラップさせることができ、意図しない操作や実装漏れを防ぐことができます。これにより、ログ出力やエラーハンドリングといった共通の処理を確実に実行させることができ、コードの動作をより信頼性の高いものにすることが可能です。

まとめ

今回紹介したように、TypeScript と Symbol を組み合わせることで、特定の処理を忘れずに実行させる仕組みを構築できます。これにより、関数のラップを強制的に適用し、特定の操作を確実に行うことができるようになります。

サンプルコード

https://github.com/ryomaejii/nextjs-logging-with-symbol

参考

https://typescriptbook.jp/reference/values-types-variables/symbol

Discussion

Honey32Honey32

失礼します。

今回のケースであれば Symbol が型チェックにしか使われておらず、実行時に「チェックしてエラーを throw する」ような使い方をしていないように見受けられます。そのような場合には、既存の Branded Types と呼ばれる手法があるので、そちらを使う方が良いと思います。

Branded Types パターンは、型チェックのみに影響を及ぼして、実行時のオブジェクトにはいっさい干渉していないので、効率的になると思います(どれほどか分かりませんが)。
加えて、方法に名前が付いていると保守性の面でもすぐれていると思います。

https://zenn.dev/okunokentaro/articles/01gmpkp9gzfyr1za5wvrxt0vy6

ryomaejiiryomaejii

ご共有ありがとうございます!
Branded Types パターン試してみます👍