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

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/


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!


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.

Tags:

Typescript