Represent Adobe's Number-like `UnitValue` in TypeScript

557 views Asked by At

Adobe's ExtendScript features the UnitValue type, representing an on-screen distance. It is a type which is similar to, but distinct from, the Number type.

  1. Each UnitValue object carries a type string, for example "cm" or "ft".
  2. Arithmetic operations between UnitValue objects carrying different type values involves an implicit coercion; arithmetic operations between a UnitValue object and a plain Number happens as-is, returning a UnitValue.
  3. Each UnitValue object carries a number of miscellaneous fields; the UnitValue prototype implements a bunch of methods.
  4. A UnitValue object is built from a constructor - var x = UnitValue(4, "cm").

How could I best represent this in TypeScript?

2

There are 2 answers

0
Alex Young On

One way of implementing it - I chose centimeters as a base unit but you could use something else. It uses instance methods for arithmetic, as stated above you can't overload operators in TypeScript. I chose to maintain the type (unit) of the instance on which the method was called rather than the argument.

See comments in the code for explanation, but ask if you have any questions.

export interface IUnitValue {
    add(a: IUnitValue): IUnitValue;
    subtract(a: IUnitValue): IUnitValue;
    toString(): string;
}

export type Unit = "cm" | "ft";

// UnitValue is a factory function that knows how to construct different
// types of UnitValueClass
export const UnitValue = (value: number, unit: Unit): IUnitValue => {
    switch (unit) {
        case "cm":
            return new UnitValueClass(value, 1, unit);
        case "ft":
            return new UnitValueClass(value, 30, unit);
    }
    throw new Error(`Unrecognised unit ${unit}`);
};

export class UnitValueClass implements IUnitValue {
    private value: number;
    private cmPerUnit: number;
    private type: Unit;

    constructor(value: number, cmPerUnit: number, unit: Unit) {
        this.value = value;
        this.cmPerUnit = cmPerUnit;
        this.type = unit;
    }

    // Return the wrapped value converted to centimeters
    private toCm(): number {
        return this.value * this.cmPerUnit;
    }

    // When adding, convert both operands to centimeters, then convert the result
    // to the correct type and return a new UnitValue
    add(a: this): IUnitValue {
        return UnitValue((this.toCm() + a.toCm()) / this.cmPerUnit, this.type);
    }

    // Same logic as adding
    subtract(a: this): IUnitValue {
        return UnitValue((this.toCm() - a.toCm()) / this.cmPerUnit, this.type);
    }

    // Make it look pretty
    toString() {
        return `${this.value} ${this.type}`;
    }
}

Used like this:

const a = UnitValue(45, "cm");
const b = UnitValue(1, "ft");

console.log(a.toString());            // 45 cm
console.log(b.toString());            // 1 ft
console.log(b.add(a).toString());     // 2.5 ft
console.log(a.subtract(b).toString());// 15 cm
0
AlexeyM On

It technically possible to implement units using generic and literal types in TypeScript:

// union of all possible unit types
type UnitType = 'cm' | 'm';

interface UnitConversion<From extends UnitType, To extends UnitType> {
    from: From;
    to: To;
    convert(value: UnitValue<From>): UnitValue<To>;
}

function conversion<From extends UnitType, To extends UnitType>(
    from: From, to: To, convert: (value: UnitValue<From>) => UnitValue<To>
): UnitConversion<From, To> {
    return { from, to, convert };
}

function identity<T extends UnitType>(t: T): UnitConversion<T, T> {
    return { from: t, to: t, convert: v => v };
}

// conversion table for each pair of unit types
const IMPLICIT_CONVERSIONS = {
    'cm': {
        'cm': identity('cm'),
        'm': conversion('cm', 'm', v => new UnitValue(v.value * 0.1, 'm')),
    },
    'm': {
        'cm': conversion('m', 'm', v => new UnitValue(v.value * 10, 'cm')),
        'm': identity('m'),
    },
};
type ImplicitConversions<
    Left extends UnitType,
    Right extends UnitType
> = (typeof IMPLICIT_CONVERSIONS)[Left][Right]['to'];

function convert(conversion: UnitConversion<any, any>, value: UnitValue<any>) {
    return value.type === conversion.to ? value : conversion.convert(value);
}

type UnitPair<T extends UnitType> = {
    left: UnitValue<T>;
    right: UnitValue<T>;
};

function convertToCommonType<Left extends UnitType, Right extends UnitType>(
    left: UnitValue<Left>,
    right: UnitValue<Right>
): UnitPair<ImplicitConversions<Left, Right>> {
    const conversion = IMPLICIT_CONVERSIONS[left.type][right.type];
    return { left: convert(conversion, left), right: convert(conversion, right) };
}

class UnitValue<Type extends UnitType> {
    constructor(
        readonly value: number,
        readonly type: Type,
    ) { }

    /** Type-safe unit addition */
    add<T extends UnitType>(value: UnitValue<T>): UnitValue<ImplicitConversions<Type, T>> {
        const { left, right } = convertToCommonType(this, value);
        return new UnitValue(left.value + right.value, left.type);
    }
}

Then use it like this:

const common = convertToCommonType(
  new UnitValue(3, 'cm'),
  new UnitValue(10, 'm')
);
// => result type: UnitValue<'m'>

const z = new UnitValue(4, 'cm').add(new UnitValue(5, 'm'));
// => result type: UnitValue<'m'>

However, it could be argued that this introduces too much complexity.