🐮

TypeScriptで安全に小数点を扱うポイント・スコアを実装する

2024/12/14に公開

はじめに

こんにちは! no plan inc. にてエンジニアやってます @somasekiです。
これはno plan inc.の Advent Calendar 2024の14日目の記事です。

この記事の内容

typescriptによるアプリケーション開発において、小数点を含むポイントやスコアの管理をする場面があると思いますが、その際に使えそうなクラスを紹介します。
既存のライブラリも存在しますが、今回は使わずにやること前提でやります。

ポイント・スコアを扱う際の課題

アプリケーション開発において、小数点を含むポイントやスコアの管理は意外と難しい問題です。特に以下のような課題があります。

  1. 浮動小数点数の精度問題

    0.1 + 0.2 // => 0.30000000000000004
    
  2. プラットフォーム間での一貫性

    • フロントエンド、バックエンド、データベース間で値が微妙に異なる可能性
    • 丸め処理による差異
  3. データベースでの扱い

    • DOUBLE型やFLOAT型では精度の保証が難しい
    • 比較やソートの際に予期せぬ結果が発生する可能性

これらの問題を解決するため、小数点以下3桁までを安全に扱えるFixedPointクラスを実装していきます。

実装

// 値の状態を表すユニオン型
type ValueState = 'stored' | 'display';

// 状態に応じた型を生成するジェネリック型
type TypedNumber<S extends ValueState> = {
  readonly value: number;
  readonly __state: S;
};

export class FixedPoint<S extends ValueState> {
  private static readonly PRECISION = 3;
  private static readonly SCALE_FACTOR = Math.pow(10, FixedPoint.PRECISION);
  private static readonly MAX_VALUE = 999999999;
  private static readonly MIN_VALUE = -999999999;

  private constructor(
    private readonly typedValue: TypedNumber<S>
  ) {}

  static validate(value: number): void {
    if (!Number.isFinite(value)) {
      throw new Error('Invalid number value');
    }
    if (value > this.MAX_VALUE || value < this.MIN_VALUE) {
      throw new Error('Value out of range');
    }
  }

  static fromNumber(value: number): FixedPoint<'display'> {
    this.validate(value);
    const roundedValue = Math.floor(value * this.SCALE_FACTOR) / this.SCALE_FACTOR;
    return new FixedPoint<'display'>({
      value: roundedValue,
      __state: 'display'
    });
  }

  static fromStoredValue(value: number): FixedPoint<'stored'> {
    this.validate(value);
    return new FixedPoint<'stored'>({
      value,
      __state: 'stored'
    });
  }

  static zero(): FixedPoint<'display'> {
    return new FixedPoint<'display'>({
      value: 0,
      __state: 'display'
    });
  }

  toDisplayValue(): FixedPoint<'display'> {
    if (this.typedValue.__state === 'display') {
      return this as FixedPoint<'display'>;
    }
    return new FixedPoint<'display'>({
      value: this.typedValue.value / FixedPoint.SCALE_FACTOR,
      __state: 'display'
    });
  }

  toStoredValue(): FixedPoint<'stored'> {
    if (this.typedValue.__state === 'stored') {
      return this as FixedPoint<'stored'>;
    }
    return new FixedPoint<'stored'>({
      value: Math.floor(this.typedValue.value * FixedPoint.SCALE_FACTOR),
      __state: 'stored'
    });
  }

  toNumber(): number {
    return this.typedValue.__state === 'stored' 
      ? this.typedValue.value / FixedPoint.SCALE_FACTOR 
      : this.typedValue.value;
  }

  toString(): string {
    return this.toNumber().toFixed(FixedPoint.PRECISION);
  }

  isZero(): boolean {
    return this.toNumber() === 0;
  }

  add(other: FixedPoint<ValueState>): FixedPoint<'display'> {
    return FixedPoint.fromNumber(
      this.toNumber() + other.toNumber()
    );
  }

  sub(other: FixedPoint<ValueState>): FixedPoint<'display'> {
    return FixedPoint.fromNumber(
      this.toNumber() - other.toNumber()
    );
  }

  mul(other: FixedPoint<ValueState>): FixedPoint<'display'> {
    return FixedPoint.fromNumber(
      this.toNumber() * other.toNumber()
    );
  }

  div(other: FixedPoint<ValueState>): FixedPoint<'display'> {
    const otherValue = other.toNumber();
    if (otherValue === 0) {
      throw new Error('Division by zero');
    }
    return FixedPoint.fromNumber(
      this.toNumber() / otherValue
    );
  }

  eq(other: number | FixedPoint<ValueState>): boolean {
    const otherValue = other instanceof FixedPoint ? other.toNumber() : other;
    return this.toNumber() === otherValue;
  }

  gt(other: number | FixedPoint<ValueState>): boolean {
    const otherValue = other instanceof FixedPoint ? other.toNumber() : other;
    return this.toNumber() > otherValue;
  }

  lt(other: number | FixedPoint<ValueState>): boolean {
    const otherValue = other instanceof FixedPoint ? other.toNumber() : other;
    return this.toNumber() < otherValue;
  }

  abs(): FixedPoint<'display'> {
    return FixedPoint.fromNumber(Math.abs(this.toNumber()));
  }

  toJSON(): { value: number; state: ValueState } {
    return {
      value: this.typedValue.value,
      state: this.typedValue.__state
    };
  }

  static fromJSON(json: { value: number; state: ValueState }): FixedPoint<ValueState> {
    this.validate(json.value);
    return new FixedPoint({
      value: json.value,
      __state: json.state
    });
  }
}

実装の解説

型安全性の確保

値の状態(保存用/表示用)を型レベルで管理することで、誤った使用を防ぎます。

// OKな例
const displayValue = FixedPoint.fromNumber(1.234);    // FixedPoint<'display'>
const storedValue = displayValue.toStoredValue();     // FixedPoint<'stored'>

// コンパイルエラーになる例
const invalid: FixedPoint<'stored'> = displayValue;   // 型エラー

値の制限と検証

無効な値や範囲外の値を確実に検出します。

static validate(value: number): void {
  if (!Number.isFinite(value)) {
    throw new Error('Invalid number value');
  }
  if (value > this.MAX_VALUE || value < this.MIN_VALUE) {
    throw new Error('Value out of range');
  }
}

イミュータブルな設計

private constructor(
  private value: StoredValue | DisplayValue,
  private isStored: boolean
) {}

constructorをprivateにし、すべての操作が新しいインスタンスを返すようにすることで、値の不変性を保証しています。

数値変換

すべての演算は新しいインスタンスを返し、精度を保証します。

const value1 = FixedPoint.fromNumber(1.234);
const value2 = FixedPoint.fromNumber(2.345);

const sum = value1.add(value2);        // 3.579
const product = value1.mul(value2);    // 2.894

シリアライズのサポート

toJSON(): { value: number; state: ValueState } {
    return {
      value: this.typedValue.value,
      state: this.typedValue.__state
    };
  }

static fromJSON(json: { value: number; state: ValueState }): FixedPoint<ValueState> {
    this.validate(json.value);
    return new FixedPoint({
        value: json.value,
        __state: json.state
    });
}

まとめ

このFixedPointクラスには以下のような特徴があります。

  1. 小数点以下N桁までの精度を保証
  2. データベースとの連携を考慮した設計
  3. 型安全性の確保
  4. イミュータブルな実装
  5. 豊富なユーティリティメソッド

注意点として

  1. パフォーマンスへの影響(変換処理のオーバーヘッド)
  2. 扱える値の範囲に制限がある
  3. メモリ使用量が若干増える

実際の使用時は、これらのトレードオフを考慮した上で、必要に応じて調整してください。

おわりに

小数点を扱う処理は一見単純に見えますが、意外と複雑な問題を含んでいます。
みなさんのプロジェクトでも、似たような課題に直面した際は、このアプローチを参考にしていただければ幸いです。

no plan株式会社について

GitHubで編集を提案

Discussion