buge / ts-units

Physical Units for TypeScript
Apache License 2.0
23 stars 7 forks source link

Alternative to native representation of numbers #5

Open Lukinoh opened 1 year ago

Lukinoh commented 1 year ago

Hello,

It is not an issue, but a question. As you may know, JavaScript and decimal it's not great love. So if you do 0.1 + 0.2 the result will be 0.30000000000000004.

Currently, in your library you are using native representation of numbers, so such issue could arise (which is fine me). However, there is no easy way to replace the default behaviour. So I had to duplicate the unit.ts and adapt it to use decimal.js.

Do you plan to offer a way to have better integration with libraries such as decimal.js ?

P.S. Don't hesitate to edit the title of the issue.

buge commented 1 year ago

Hey Lukinoh,

My apologies for the delayed response. I think this is totally possible. We'd basically have something like:

export interface Quantity<V, D extends Dimensions> {
  readonly amount: V;
  // ...
}

It would be super nice if we could just automatically support being able to write something like:

const numberLength = meters(5);
const bigIntLength = meters(new BigInt('5'));
const decimalLength = meters(new Decimal('5'));

The primary issue is that the different numeric types have different operators for the same arithmetic operation, so you need to tell the library somehow how to add, subtract, multiply and divide numbers. To be clear, I'm not worried about the extra computational overhead of introducing an indirection through a function call (any decent JS environment like V8 will optimize it by inlining anyways), it's more that I'm not sure what the right API is.

Thinking out loud:

  1. If you use a non-native numeric type, you must supply arithmetic information:

    const decimalArithmetic: Arithmetic<Decimal> = {
      add: (a, b) => a.plus(b),
      sub: (a, b) => a.minus(b),
    };

    But then the uses of the built-in units becomes quite verbose and it feels kinda redundant:

    const decimalLength = meters(new Decimal('5'), decimalArithmetic);
  2. We could simplify this by adding a method to Unit:

    export interface Unit<V, D> {
      withValueType<V2>(math: Arithmetic<V2>): Unit<V2, D>
      // ...
    }

    Then an import just becomes:

    import {meters as numberMeters} from '@buge/ts-units/length';
    const meters = numberMeters.withValueType(decimalArithmetic);
    
    const decimalLength = meters(new Decimal('5'));

    That import still stumbles a bit, so perhaps we can add a utility function to each group of units:

    import {withValueType} from '@buge/ts-units/length';
    import * as dimension from '@buge/ts-units/length/dimension';
    
    const {
     meters
    } = withValueType(decimalArithmetic);
    
    type Length = Quantity<Decimal, dimension.Length>;
    
    const decimalLength: Length = meters(new Decimal('5'));

I kinda like variant 2, I'm mostly wondering about the right name for withValueType. Any suggestions?

I also just realized that we could further simplify the quantity creation even further to avoid the annoying new Decimal calls even further if we added a create option to Arithmetic:

const decimalArithmetic: Arithmetic<Decimal, number|string> = {
  create: (v: number|string) => new Decimal(v),
  add: (a: Decimal, b: Decimal) => a.plus(b),
  sub: (a: Decimal, b: Decimal) => a.minus(b),
};

const {meters} = withValueType(decimalArithmetic);
const decimalLength: Length = meters('5');

What do you think?

Lukinoh commented 1 year ago

Hello,

I would probably go with the Variant 2 and the withValueType for each group of units. I agree, having a create option is a good idea to simplify the quantity creation.

Concerning the naming of withValueType, maybe we could name it withNumericType instead. As we are trying to redefine the numeric type used.


Just a side note. Before starting using your library, I was using another one that is not maintained anymore. They had an implementation for this feature.

Lukinoh commented 1 year ago

Hello @buge ,

I am currently working on two implementations of the proposition.

The first one is based on adding an arithmetic interface as proposed above.

For instance: ((1+2) * 3) / (1 + 1) becomes div(mul(add(1, 2), 3), add(1, 1)))

It is working, but I found the solution quite hard to read. So I am working on another alternative based on wrapping the number type in some kind of builder-like.

export interface NumberWrapper<NumberType> {
  // We will have a constructor that transform native number to the class that implement this interface.
  add(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  sub(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  mul(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  div(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  pow(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  abs(): NumberWrapper<NumberType>;
  toNative(): number;
}

With this solution the calculus becomes more readable from my point of view. ((1+2) * 3) / (1 + 1) becomes 1.plus(2).mul(3).div(1.plus(1)). However, overall the implementation is a bit more complex. I am not sure it is worth as there is not a lot of calculation.

So I would like to know which solution you prefer.

edit: I may have found a better compromise, I will try it when I have a bit of time

Lukinoh commented 1 year ago

Hello, So to summize there are at least 3 implementations.

NumberType + Arithemetic

This solution is based on the one you proposed.

export interface Arithmetic<NumberType> {
  fromNative(value: number): NumberType;
  toNative(value: NumberType): number;
  add(left: NumberType, right: NumberType): NumberType;
  sub(left: NumberType, right: NumberType): NumberType;
  mult(left: NumberType, right: NumberType): NumberType;
  div(left: NumberType, right: NumberType): NumberType;
  pow(base: NumberType, exponent: NumberType): NumberType;
  abs(value: NumberType): NumberType;
}

This is probably the simplest solution overall, however reading the calculation can become a bit cumbersome. add(mul(div(amount, unit), other.amount), fromNative(4)) It is probably also the more efficient as we do not create new instance of classes as the 2 other propositions below.

WrappedNumberType

This solution will wrap the NumberType into another class that allows to do directly calculation

export interface NumberWrapper<NumberType> {
  add(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  sub(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  mul(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  div(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  pow(value: NumberWrapper<NumberType>): NumberWrapper<NumberType>;
  abs(): NumberWrapper<NumberType>;
  toNative(): number;
}

// from: number => NumberType

amount.div(unit).mul(other.amount).add(from(4))

The calculation is more readable, but add complexity by wrapped the value into a class.

NumberType with Builder-like Arithematic

This solution is a bit a mix of the both above. So we keep the value as a NumberType, but the Arithematic is kind of a builder.

export interface Arithmetic<NumberType> {
  add(value: NumberType): Arithmetic<NumberType>;
  sub(value: NumberType): Arithmetic<NumberType>;
  mul(value: NumberType): Arithmetic<NumberType>;
  div(value: NumberType): Arithmetic<NumberType>;
  pow(value: NumberType): Arithmetic<NumberType>;
  abs(): Arithmetic<NumberType>;
  val(): NumberType;
  toNative(): number;
}

export interface ArithmeticFactory<NumberType> {
  convert(value: number): NumberType;
  from(value: number): Arithmetic<NumberType>;
  from(value: NumberType): Arithmetic<NumberType>;
}

from(amount).div(unit).mul(other.amount).add(convert(4)).val()

With this solution you are avoiding wrapping, but adding a bit of overhead to calculation.


Which solution do you prefer ?

Edit: I went for "NumberType + Arithemetic" at the end it is the best and more flexible.