TypeScriptの再帰的型システムを駆使した複雑な数式の計算
はじめに
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
この実装により、型安全に数式を表現し、再帰的な評価を行うことが可能になります。
データベース連携と型の拡張
実際の実装では、数値データはデータベース上に保管されているため、次のような拡張を加えました。
DataRef
の追加
1. データベース参照のための型 ユーザーが指定する数値の出所(テーブルやカラム)を示すために、以下のような型を導入しました。
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);
}
}
sum
型
2. 複数の値を一度に計算するための 数値の合計をより簡潔に表現するため、再帰的な 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の型システムを最大限に活用することで、従来の文字列ベースのパーサー実装に比べ、エラーが少なく、コードが短く読みやすいものになると思います。
Discussion