Zenn

TypeScriptの再帰的型システムを駆使した複雑な数式の計算

2025/02/25に公開
3

はじめに

Webアプリケーション開発の現場では、ユーザーが直感的にUI上で作成した数式や計算式をデータベースに保存し、必要に応じて動的に計算する仕組みが求められるケースが増えています。
特に、複数のデータベース上の数値を組み合わせた複雑な計算を、ユーザーの意図通りに実現する際、従来の文字列ベースの実装ではパーサーの開発やエラー処理が非常に煩雑になりがちです。

本プロジェクトにおいても、以下の要件がありました:

  • ユーザーがUIで作成した数式をデータベースに保管したい
  • 数式の計算に必要な数値データは既にデータベース上に存在し、動的な処理が求められる
  • 文字列を用いたパーサー実装や、エラー処理の複雑さを避けたい
  • TypeScriptの強力な型システムや再帰型の機能を最大限に活用したい

そこで、関数型言語、Haskellの型定義と評価eval関数を参考にしながら、TypeScriptで安全かつ拡張性の高い実装を目指すアプローチに取り組みました。


Haskellによる実装例

参考として、まずはHaskellでの実装例を見てみます。Haskellでは以下のように、数式を表現する再帰的なデータ型 Expr を定義します。

data Expr
    = Val Int
    | Add Expr Expr
    | Sub Expr Expr
    | Mul Expr Expr
    | Div Expr Expr

このデータ型 Evalは、以下のいずれかのように解釈できます。

  • Val: 整数の値を保持
  • Add, Sub, Mul, Div: 左辺と右辺という再帰的な式を保持し、それぞれ加算、減算、乗算、除算を表す

たとえば、

  • Add (Val 1) (Val 2) は数式 1 + 2
  • Mul (Add (Val 1) (Val 2)) (Val 3) は数式 (1 + 2) * 3
    を表しています

評価関数 eval を以下のように定義することで上のようなExprを再帰的に計算できます。

eval :: Expr -> Int
eval (Val n)     = n
eval (Add e1 e2) = eval e1 + eval e2
eval (Sub e1 e2) = eval e1 - eval e2
eval (Mul e1 e2) = eval e1 * eval e2
eval (Div e1 e2) = eval e1 `div` eval e2

要するに、Add, Sub, Mul, Divは右辺と左辺を再帰的に計算し、その2つの計算結果に対して和差積商の正しい演算を加えてreturnしていく感じになります。


TypeScriptでの実装

Haskellの書き方をもとに、TypeScriptで同様の再帰的な型を定義してみました。まずはシンプルな実装例です。

型定義

type Expr =
  | { type: "val"; value: number }
  | { type: "add"; left: Expr; right: Expr }
  | { type: "sub"; left: Expr; right: Expr }
  | { type: "mul"; left: Expr; right: Expr }
  | { type: "div"; left: Expr; right: Expr };

各型は、値または左右の式を保持することで、再帰的な構造を持たせています。

評価関数

Haskellの例と同じように、evalExprは再帰的に左辺と右辺の計算を行います。

function evalExpr(expr: Expr): number {
  switch (expr.type) {
    case "val":
      return expr.value;
    case "add":
      return evalExpr(expr.left) + evalExpr(expr.right);
    case "sub":
      return evalExpr(expr.left) - evalExpr(expr.right);
    case "mul":
      return evalExpr(expr.left) * evalExpr(expr.right);
    case "div":
      return evalExpr(expr.left) / evalExpr(expr.right);
  }
}

使用例

const expr: Expr = {
  type: "add",
  left: { type: "val", value: 3 },
  right: {
    type: "mul",
    left: { type: "val", value: 2 },
    right: { type: "val", value: 4 },
  },
};

console.log(evalExpr(expr)); // 11

この実装により、型安全に数式を表現し、再帰的な評価を行うことが可能になります。


データベース連携と型の拡張

実際の実装では、数値データはデータベース上に保管されているため、次のような拡張を加えました。

1. データベース参照のための型 DataRef の追加

ユーザーが指定する数値の出所(テーブルやカラム)を示すために、以下のような型を導入しました。

type Expr =
  // ... (他のケースは前と同じ)
  | { type: "dataRef"; table: string; column: string };

そして、データベースから数値を取得する関数 fetchData を実装します。

function fetchData(table: string, column: string): number {
  // ここにデータベースから値を取得するロジックを実装
  return 42; // 仮の値
}

function evalExpr(expr: Expr): number {
  switch (expr.type) {
    // ...(他のケースは前と同じ)
    case "dataRef":
      return fetchData(expr.table, expr.column);
  }
}

2. 複数の値を一度に計算するための sum

数値の合計をより簡潔に表現するため、再帰的な add の代わりに、複数の項を扱える sum 型を用意しました。これにより、データベースに保存する際のstringの長さを減らす狙いもあります。

type Expr =
  // ...(他のケースは前と同じ)
  | { type: "sum"; terms: Expr[] };

function evalExpr(expr: Expr): number {
  switch (expr.type) {
    // ...(他のケースは前と同じ)
    case "sum":
      return expr.terms.reduce((acc, term) => acc + evalExpr(term), 0);
  }
}

エラーハンドリングと実運用での工夫

実際の運用環境では、以下のような点を考慮してさらに機能を充実させました

  • 0除算の対応: div の評価時に0で除算しないようエラーチェックを追加
  • データ取得エラー: データベースから値が取得できなかった場合のフォールバック処理

まとめ

今回、TypeScriptの再帰型を利用して、関数型言語的なアプローチで数学演算の動的処理を実現する方法を紹介しました。
このアプローチのメリットは以下の通りです。

  • 型安全性: コンパイル時に不正な数式が検出され、実行時エラーを防止できる
  • メンテナンス性の向上: 明確な型定義により、後から機能を拡張しやすい
  • データベースとの連携: 構造化データとして保存することで、クエリや更新が容易に
  • エラーハンドリング: データ型と例外処理により、エラーに対応しやすいシステム設計が可能に

TypeScriptの型システムを最大限に活用することで、従来の文字列ベースのパーサー実装に比べ、エラーが少なく、コードが短く読みやすいものになると思います。

3
codeciaoテックブログ

Discussion

ログインするとコメントできます