That is because TypeScript type system is "structural", so any two types with the same shape will be assignable one to each other - as opposed to "nominal", where introducing a new name like Foo
would make it non-assignable to a same-shape Bar
type, and viceversa.
There's this long standing issue tracking nominal typings additions to TS.
One common approximation of opaque types in TS is using a unique tag to make any two types structurally different:
// opaque type module:
export type EUR = { readonly _tag: 'EUR' };
export function eur(value: number): EUR {
return value as any;
}
export function addEuros(a: EUR, b: EUR): EUR {
return ((a as any) + (b as any)) as any;
}
// usage from other modules:
const result: EUR = addEuros(eur(1), eur(10)); // OK
const c = eur(1) + eur(10) // Error: Operator '+' cannot be applied to types 'EUR' and 'EUR'.
Even better, the tag can be encoded with a unique Symbol to make sure it is never accessed and used otherwise:
declare const tag: unique symbol;
export type EUR = { readonly [tag]: 'EUR' };
Note that these representation don't have any effect at runtime, the only overhead is calling the eur
constructor.
newtype-ts provides generic utilities for defining and using values of types that behave similar to my examples above.
Branded types
Another typical use case is to keep the non-assignability only in one direction, i.e. deal with an EUR
type which is assignable to number
:
declare const a: EUR;
const b: number = a; // OK
This can be obtained via so called "branded types":
declare const tag: unique symbol
export type EUR = number & { readonly [tag]: 'EUR' };
See for instance this usage in the io-ts
library.
与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…