dividab / uom

Extensible unit of measure conversion with type safety for typescript
MIT License
18 stars 1 forks source link

How to extend this package from an external package #19

Closed jonaskello closed 4 years ago

jonaskello commented 5 years ago

This package is somewhat extensible because you can define new Units. However there is no clear strategy how you would package extended units and how you can use them with this package.

jonaskello commented 5 years ago

I think extended units would be hard to use as it is now, becuase the Quantity type is expected to the type defined in this package. For example Amount.create is defined as:

export function create<T extends Quantity>(
  value: number,
  unit: Unit.Unit<T>,
  decimalCount: number | undefined = undefined
): Amount<T> {

So if there is an external unit with an external quantity it would not pass the constraint for T extends Quantity because here Quantity refers to the type defined in this package and it does not include externally defined Quantity types.

jonaskello commented 5 years ago

Any extended units should also be possible to use with the Format and Serialize modules, not sure how that would work. One way could be that the functions in those packages accepts a map of Units so you can call them with a map of Units defined in an external package. Today they internally use import * as Units from "./units" as a map of units when doing lookups.

jonaskello commented 5 years ago

One way to make it easier to extend might be to not use T extends Quantity but just T instead. Not sure of the impact that would have?

jonaskello commented 5 years ago

@AdamLuotonen @geon Any thoughts on the above?

jonaskello commented 5 years ago

We can limit the number of available units by having the application creating and using it's own Units module by re-exporting from the standard Units module:

my-units.ts

export { Meter, CentiMeter } from "standard-units/length";
export { Celsius, Kelvin } from "standard-units/temperature";

my-unit.formats.ts

export { Meter, CentiMeter } from "standard-unit-formats/length";
export { Celsius, Kelvin } from "standard-unit-formats/temperature";

application.ts

import * as Units from "./my-units";
const amount1 = Amount.create(10.0, Units.Meter) // OK
const amount2 = Amount.create(10.0, Units.MilliMeter) // NOT OK

const label = Format.getUnitFormat(amount1.unit);
// Need to pass in your own Units
const lengthUnits = Format.getUnitsForQuantity("Length", Units);

It is also possible add your own new units to the file like this:

foo-quantity.ts

import * as Unit from "../unit";

export type Foo = "Foo";

export const Bar = Unit.createBase("Bar", "Foo", "bar");
export const Zoo = Unit.createBase("Zoo", "Foo", "zoo");

my-units.ts

import { Units } from "uom";

export { Meter, CentiMeter } from "uom/lib/units/length";
export { Celsius, Kelvin } from "uom/lib/units/temperature";
export * from "./foo-quantity";

This will fall apart when trying to use these units with Amount.createAmount<Quantity>() because the quantity Foo is not part of the Quantity union-type.

You can create your own Quantity union-type like this:

my-quantity.ts

import * as Units from "./my-units";

export { Temperature } from "uom/lib/units/temperature";
export { Length } from "uom/lib/units/length";
export { Foo } from "./foo-quantity";

export type Quantity =
  | Units.Length
  | Units.Temperature
  | Units.Foo

But how do you make Amount use that Quantity type instead of the internal one?

We can try the re-export strategy for Amount but we will probably have to wrap every function:

my-amount.ts

import { Amount } from "uom";
import { Quantity } from "./my-quantity";

export function create<T extends Quantity>(
  value: number,
  unit: Unit.Unit<T>,
  decimalCount: number | undefined = undefined
): Amount<T> {
  return Amount.create(value, unit, decimalCount);
}

export function toString<T extends Quantity>(amount: Amount<T>): string {
  return Amount.toString(amount);
}

One way would be to remove the extends Quantity from the built-in Amount module functions. Perhaps it is enough that the Unit<T> exists. In this case it will be possible to pass a strange quantity, such as Amount.create<"NoQuantity">(10.2, unit) but in order to do that you need a unit that fits with that so perhaps that is enough to avoid mistakes?

A function that accepts amounts of user defined quantities:

application.ts

import { Amount } from "uom";
import * as Units from "./my-units";
import { Temperature, Foo } from "./my-quantity";

calculate(Amount.create(10, Units.Celsius), Amount.create(11, Amount.Bar));

function calculate(t: Amount<Temperature>, f: Amount<Foo>): Amount<Foo> {
  // Do something useful
}
jonaskello commented 5 years ago

In the Amount module, the reason why we have T extends Quantity instead of just T is that Unit is defined as Unit<T extends Quantity>. There is no reason for the Amount module itself to have extends Quantity.

For example this function in Amount:

export function create<T extends Quantity>(
  value: number,
  unit: Unit.Unit<T>,
  decimalCount: number | undefined = undefined
): Amount<T>

In the above the important constraint is that the T in the resulting Amount<T> is the same as the T in the Unit<T> passed in. Restricting T to Quantity is not really important. However since the T in Unit is restricted, we have to add the extends Quantity to Amount.create.

So the question becomes if it is important to have Unit<T extends Quantity> instead of just Unit<T>.

jonaskello commented 5 years ago

Here is an example function from the Unit module:

export function timesNumber<T extends Quantity>(
  name: string,
  factor: number,
  unit: Unit<T>
): Unit<T> {
  return transform(name, createFactorConverter(factor), unit);
}

In this case what is important is that the resulting Unit<T> has the same T as the one passed in. So the extends Quantity does not do anything meaningful in this case.

jonaskello commented 5 years ago

I think it may boil down to that extends Quantity restricts you from constructing an Unit with an invalid Quantity. So the check is only really important when constructing a Unit. Once a Unit<T> is constructed, it will be used as parameter to other functions which have more T that should be the same as the Unit's T. And if the Unit is constructed with a valid T, all other T in functions etc. will also be valid.

And having a "valid" T in Unit<T> is only useful if you defined a function that takes more than one quantity. For example this function will only take "Length" as a T so it is already constraining the T without any help from extends Quantity:

function calculate(amount<Length> a)

However this function accepts any "valid" quantity:

function calculate(amount<Quantity> a)

But even in the above function T extends Quantity is not really doing anything useful since the function specifies Quantity and that is a union type, so the set of types that you can pass is restricted by the union type rather than the T extends Quantity.

jonaskello commented 5 years ago

If we can remove T extends Quantity we can probably also remove the built-in Quantity union type.

geon commented 5 years ago

There is no reason for the Amount module itself to have extends Quantity.

A valid quantity in Amount<T> is useful when writing external code that uses it. For example, I might take a length as an argument in a function, but not care about the unit.

function isTooLong(length: Amount<"Length">): boolean {
    return Amount.greaterThan(length, Amount.create(42, Units.Meter));
}

How would I express that without having Amount<T>, where T is a quantity?

jonaskello commented 5 years ago

There would still be Amount<T> where T is a Quantity. It is only the constraint in on T that would be removed, so instead of Amount<T extends Quantity> there would be Amount<T> where T is still a Quantity. However since there is no constraint, you can pass anything as T, but that is not really true becuase the T would sooner or later have to be matched against the T in a Unit<T> anyway.

The example above would work even without the extends Quantity constraint on T I think?

And if you do something silly like this:

function isTooLong(length: Amount<"Foo">): boolean {
    return Amount.greaterThan(length, Amount.create(42, Units.Meter));
}

It will not compile because the T in Amount.greaterThan<T> would not match the T in the Unit<T> returned by Units.Meter.