Is there a way to create nominal types in TypeScript that extend primitive types?

前端 未结 5 622
悲&欢浪女
悲&欢浪女 2020-12-01 14:39

Say I have two types of numbers that I\'m tracking like latitude and longitude. I would like to represent these variables with the basic num

相关标签:
5条回答
  • 2020-12-01 14:42

    There isn't a way to do this.

    A suggestion tracking this on the GitHub site is Units of Measure.

    In a future release, you'll be able to use type to define alternate names for the primitives, but these will not have any checking associated with them:

    type lat = number;
    type lon = number;
    var x: lat = 43;
    var y: lon = 48;
    y = 'hello'; // error
    x = y; // No error
    
    0 讨论(0)
  • Actually there is a way to achieve what you want to achieve, but it's a bit tricky, has some limitations and could be completely unreasonable to a person who sees that code for the first time, so treat it as a curiosity rather than actual implementation ;)

    Okay, so let's go. First, we need to create a "subclass" of Number. The problem is, that lib.d.ts actually declares Number as an interface, not a class (which is reasonable - no need to implement methods, browser takes care of that). So we have to implement all the methods declared by the interface, thankfully we can use existing implementation of declared var Number.

    class WrappedNumber implements Number {
        //this will serve as a storage for actual number
        private value: number;
    
        constructor(arg?: number) {
            this.value = arg;
        }
    
        //and these are the methods needed by Number interface
        toString(radix?: number): string {
            return Number.prototype.toString.apply(this.value, arguments);
        }
    
        toFixed(fractionDigits?: number): string {
            return Number.prototype.toFixed.apply(this.value, arguments);
        }
    
        toExponential(fractionDigits?: number): string {
            return Number.prototype.toExponential.apply(this.value, arguments);
        }
    
        toPrecision(precision: number): string {
            return Number.prototype.toPrecision.apply(this.value, arguments);
        }
    
        //this method isn't actually declared by Number interface but it can be useful - we'll get to that
        valueOf(): number {
            return this.value;
        }
    }
    

    There you go, we created a type WrappedNumber which behaves just like number type. You can even add two WrappedNumbers - thanks to the valueOf() method. 2 limitations here, however: first, you need to cast variables to perform this operation. Second: the result will be a regular number, so it should be again wrapped afterwards. Let's look at an example of addition.

    var x = new WrappedNumber(5);
    var y = new WrappedNumber(7);
    
    //We need to cast x and y to <any>, otherwise compiler
    //won't allow you to add them
    var z = <any>x + <any>y;
    
    //Also, compiler now recognizes z as of type any.
    //During runtime z would be a regular number, as
    //we added two numbers. So instead, we have to wrap it again
    var z = new WrappedNumber(<any>x + <any>y); //z is a WrappedNumber, which holds value 12 under the hood
    

    And here comes the most, in my opinion, tricky part. We now create 2 classes, Latitude and Longitude which will inherit from WrappedNumber (so that they behave as numbers)

    class Latitude extends WrappedNumber {
        private $;
    }
    class Longitude extends WrappedNumber {
        private $;
    }
    

    What the heck? Well, TypeScript uses duck typing when comparing types. Which means that two different types are considered to be "compatible" (and therefore assignable to itselves, i.e you can assign variable of one type to a value of other) when they have the same set of properties. And the solution is really simple: add a private member. This private member is pure virtual, it's not used anywhere and won't be compiled. But it makes TypeScript think that Latitude and Longitude are completely different types and, which we are interested in more, won't allow to assign variable of type Longitude to that of type Latitude.

    var a = new Latitude(4);
    var b: Longitude;
    b = a; //error! Cannot convert type 'Latitude' to 'Longitude'
    

    Voila! That's what we wanted. But the code is messy and you need to remember to cast types, which is really inconvenient, so don't use that actually. However, as you see, it's possible.

    0 讨论(0)
  • 2020-12-01 14:50

    Based on Lodewijk Bogaards answer

    interface Casted extends Number {
      DO_NOT_IMPLEMENT
      toManipulate: { castToNumberType:numberType, thenTo: number } 
    }
    
    interface LatitudeNumber extends Casted {
      LatitudeNumber
    }
    
    interface LongitudeNumber extends Casted {
      LongitudeNumber
    }
    type numberType = number | Casted
    var lat = <LatitudeNumber><numberType>5
    
    function doSomethingStupid(long: LongitudeNumber,lat: LatitudeNumber) {
      var x = <number><numberType>long;
      x += 25;
      return { latitude:lat, longitude:<LongitudeNumber><numberType>x }
    }
    
    var a = doSomethingStupid(<LongitudeNumber><numberType>3.067, lat)
    
    doSomethingStupid(a.longitude,a.latitude)
    

    I think doing the direct cast keeps the intention of a nominal type clear, the numberType Type unfortunately is needed because of a strange design descision where casting to number or Number still won't allow additions. The transpiled javascript is very simple with no boxing:

    var lat = 5;
    
    function doSomethingStupid(long, lat) {
        var x = long;
        x += 25;
        return { latitude: lat, longitude: x };
    }
    var a = doSomethingStupid(3.067, lat);
    doSomethingStupid(a.longitude, a.latitude);
    
    0 讨论(0)
  • 2020-12-01 14:52

    Here is a simple way to achieve this:

    Requirements

    You only need two functions, one that converts a number to a number type and one for the reverse process. Here are the two functions:

    module NumberType {
        /**
         * Use this function to convert to a number type from a number primitive.
         * @param n a number primitive
         * @returns a number type that represents the number primitive
         */
        export function to<T extends Number>(n : number) : T {
            return (<any> n);
        }
    
        /**
         * Use this function to convert a number type back to a number primitive.
         * @param nt a number type
         * @returns the number primitive that is represented by the number type
         */
        export function from<T extends Number>(nt : T) : number {
            return (<any> nt);
        }
    }
    

    Usage

    You can create your own number type like so:

    interface LatitudeNumber extends Number {
        // some property to structurally differentiate MyIdentifier
        // from other number types is needed due to typescript's structural
        // typing. Since this is an interface I suggest you reuse the name
        // of the interface, like so:
        LatitudeNumber;
    }
    

    Here is an example of how LatitudeNumber can be used

    function doArithmeticAndLog(lat : LatitudeNumber) {
        console.log(NumberType.from(lat) * 2);
    }
    
    doArithmeticAndLog(NumberType.to<LatitudeNumber>(100));
    

    This will log 200 to the console.

    As you'd expect, this function can not be called with number primitives nor other number types:

    interface LongitudeNumber extends Number {
        LongitudeNumber;
    }
    
    doArithmeticAndLog(2); // compile error: (number != LongitudeNumber)
    doArithmeticAndLog(NumberType.to<LongitudeNumber>(2)); // compile error: LongitudeNumer != LatitudeNumber
    

    How it works

    What this does is simply fool Typescript into believing a primitive number is really some extension of the Number interface (what I call a number type), while actually the primitive number is never converted to an actual object that implements the number type. Conversion is not necessary since the number type behaves like a primitive number type; a number type simply is a number primitive.

    The trick is simply casting to any, so that typescript stops type checking. So the above code can be rewritten to:

    function doArithmeticAndLog(lat : LatitudeNumber) {
        console.log(<any> lat * 2);
    }
    
    doArithmeticAndLog(<any>100);
    

    As you can see the function calls are not even really necessary, because a number and its number type can be used interchangeably. This means absolutely zero performance or memory loss needs to be incurred at run-time. I'd still strongly advise to use the function calls, since a function call costs close to nothing and by casting to any yourself you loose type safety (e.g doArithmeticAndLog(<any>'bla') will compile, but will result in a NaN logged to the console at run-time)... But if you want full performance you may use this trick.

    It can also work for other primitive like string and boolean.

    Happy typing!

    0 讨论(0)
  • 2020-12-01 15:02

    You can approximate opaque / nominal types in Typescript using a helper type. See this answer for more details:

    // Helper for generating Opaque types.
    type Opaque<T, K> = T & { __opaque__: K };
    
    // 2 opaque types created with the helper
    type Int = Opaque<number, 'Int'>;
    type ID = Opaque<number, 'ID'>;
    
    // works
    const x: Int = 1 as Int;
    const y: ID = 5 as ID;
    const z = x + y;
    
    // doesn't work
    const a: Int = 1;
    const b: Int = x;
    
    // also works so beware
    const f: Int = 1.15 as Int;
    

    Here's a more detailed answer: https://stackoverflow.com/a/50521248/20489

    Also a good article on different ways to to do this: https://michalzalecki.com/nominal-typing-in-typescript/

    0 讨论(0)
提交回复
热议问题