32

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 number primitive, but disallow assignment of a longitude to a latitude variable in typescript.

Is there a way to sub-class the number primitive so that typescript detects this assignment as illegal? Someway to coerce nominal typing so that this code fails?

var longitude : LongitudeNumber = new LongitudeNumber();
var latitude : LatitudeNumber;
latitude = longitude; // <-- type failure

The answer to "How to extend a primitive type in typescript?" seems like it will put me in the right direction, but I am not sure how to extend that solution to create distinct nominal sub-types for different kinds of numbers.

Do I have to wrapper the primitive? If so, can I make it behave somewhat seamlessly like a normal number or would I have to reference a sub-member? Can I just somehow create a typescript compile-time number subclass?

Community
  • 1
  • 1
Ross Rogers
  • 23,523
  • 27
  • 108
  • 164
  • I think the answers to this question are pretty interesting. But honestly, what purpose does arbitrarily differentiating two types with the same semantic properties have other than trying to be fancy? If both longitude and latitude are just `Numbers` why not just use `Number`? – FK82 May 27 '19 at 20:41
  • 1
    @FK82, is a latitude a longitude? They can both be represented by a `float` and with javascript, if you accidentally add a latitude to a longitude, your code continues, but there is a semantic difference. There are all sorts of numbers that would be nice if the type checker told you when you were accidentally mixing them. The area of a polygon vs the length of one of its sides. Two different types of strings - sanitized and unsanitized user input. Having the type checker separate these _semantically_ different types is very useful. – Ross Rogers May 27 '19 at 23:03
  • 1
    That's a valid point, but as far as I can see, you don't make any difference between latitude and longitude. If you want to constrain e.g. applicable operations, I think you should use a class—or, an interface as a lightweight alternative—instead. You get type checks for free with the class declaration but of course have to sacrifice number operators. – FK82 May 28 '19 at 12:17
  • 2
    @FK82, Or...a nominal type. That's what nominal types are for. You don't have to create a wrapper class, because odds are you won't create a nominal type for something as basic as a latitude or a longitude. However, if it is outrageously easy and cheap, then you are more likely to use it due to convenience and thus you'll drastically reduce the occurrence of a type of bug. I know how to work around it with classes. I asked this question about nominal types, because there is definitely a class of data types where the ergonomics of nominal types helps you implement improved type checking. – Ross Rogers May 28 '19 at 14:47
  • 1
    I'm not arguing the use of nominal types. What you want however is an **opaque type alias** which is sometimes not opaque. Meaning that you want to treat e.g. `Latitude` as a different type than `Number`; but at the same time want it to behave exactly like `Number` (e.g. for number operations). That seems contradictory. [Flow apparently has opaque type aliases](https://flow.org/en/docs/types/opaque-types/) which address this issue by making the alias transparent inside the defining module and opaque outside of it. – FK82 May 28 '19 at 20:01
  • Great. So type erasure and named types. Glad we're on the same page. – Ross Rogers May 28 '19 at 20:09

8 Answers8

22

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/

bingles
  • 11,582
  • 10
  • 82
  • 93
  • This should be the accepted answer IMO. Every other solution has a non-zero impact on run-time performance. – Jonah Feb 12 '20 at 17:52
  • This is fantastic. Single problem is people who might want to access `__opaque__` – Lou Garczynski Mar 26 '21 at 16:31
  • This is a great answer, but anybody using it should scroll down to [this other answer](https://stackoverflow.com/a/68693007/26286) which suggests replacing the opaque key with a unique symbol. – Coderer Sep 07 '21 at 10:37
14

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!

Lodewijk Bogaards
  • 19,777
  • 3
  • 28
  • 52
8

With unique symbols, introduced in Typescript 2.7, this can actually be done pretty nicely in two lines:

declare const latitudeSymbol: unique symbol;
export type Latitude = number & { [latitudeSymbol]: never };

This way, Latitudes are numbers (and can be used like them), but plain numbers are not latitudes.

Demo

let myLatitude: Latitude;
myLatitude = 12.5 as Latitude; // works
myLatitude = 5; // error
let myOtherLatitude: Latitude = myLatitude // works
let myNumber: number = myLatitude // works
myLatitude = myNumber; // error

const added = myLatitude + myOtherLatitude; // works, result is number

The error message is mostly fine, if you ignore the second line:

Type 'number' is not assignable to type 'Latitude'.
  Type 'number' is not assignable to type '{ [latitudeSymbol]: never; }'.ts(2322)

Remarks

The unique symbol declares a new symbol that we require as an attribute to Latitude. Since we don't export the symbol, it can't be accessed and is thus invisible to consumers.

This is very similar to the technique in biggle's answer, except that it covers the objection in the comments:

Single problem is people who might want to access __opaque__ – Louis Garczynski

By the way: You are in good company if you do this, React and Redux are using similar hacks.

georch
  • 959
  • 7
  • 18
4

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
Ryan Cavanaugh
  • 209,514
  • 56
  • 272
  • 235
  • I realize that type aliases are easier to implement, but why introduce the flaccid type aliasing of C/C++ when you can have more robust compile-time type checking without doing something like relying on [human pattern matching using "Apps Hungarian"](http://www.joelonsoftware.com/articles/Wrong.html). Anyways, thanks for the free compiler :-) – Ross Rogers Nov 07 '14 at 23:03
  • Type aliases were mostly added to make dealing with union types easier. Slightly improving the experience for primitives was a side effect of that. I hope we can eventually have a better solution for this, whether it be units of measure or a way to make a branded subtype of the primitives. – Ryan Cavanaugh Nov 07 '14 at 23:24
4

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.

Kuba Jagoda
  • 4,908
  • 2
  • 19
  • 21
  • My hearts' desire is to also get ["type erasure"](http://en.wikipedia.org/wiki/Type_erasure) so that the run-time cost is the same as using a plain old number. Anyways, this looks rather promising. Thanks. – Ross Rogers Nov 09 '14 at 17:37
  • If the thing is about no runtime cost then sadly I'm pretty sure this is impossible, at least with current TypeScript. – Kuba Jagoda Nov 09 '14 at 17:50
3

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);
Community
  • 1
  • 1
James Wakefield
  • 526
  • 3
  • 11
0

Here's my version of @bingles code.

The compiler displays DocumentID instead of Opaque<string, "DocumentID">, which I find more readable. You can always [ctrl+click] into [back] (depends on your ide shortcut) to quickly glance at the actual type.

class ID<T> {
  // NOTE: undefined is absolutely necessary, otherwise variables of the union type will be flagged as never
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  public _: T | undefined;
}

export type DocumentId = string & ID<"DocumentId">;
CiriousJoker
  • 552
  • 1
  • 7
  • 18
0

Specifically for Ids whose only purpose is assignment, comparison and round-tripping, and where the actual primitive type is unimportant, I'm using this version:

declare const IdSymbol: unique symbol;
type Id<T> = unknown & { readonly [IdSymbol]: T }
JJJ
  • 509
  • 1
  • 6
  • 14