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, Latitude
s are number
s (and can be used like them), but plain number
s 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.