There are numerous methods to achieve this task, all of which involve tagging the target type using intersections.
Utilizing Enum Tags
We can exploit the fact that TypeScript has one nominal type - the Enum
type to differentiate structurally identical types:
An enum type serves as a distinct subtype of the Number primitive type
What does this signify?
Interfaces and classes are assessed structurally
interface First {}
interface Second {}
var x: First;
var y: Second;
x = y; // Compiles because First and Second are structurally equivalent
Enums differ based on their "identity" (e.g. they are nominatively typed)
const enum First {}
const enum Second {}
var x: First;
var y: Second;
x = y; // Compilation error: Type 'Second' is not assignable to type 'First'.
We can utilize the nominal typing of Enum
to tag or brand our structural types in two ways:
Tagging Types with Enum Types
By making use of intersection types and type aliases in TypeScript, we can label any type with an enum and designate it as a new type. We can then cast any instance of the base type to the tagged type effortlessly:
const enum MyTag {}
type SpecialString = string & MyTag;
var x = 'I am special' as SpecialString;
// The type of x is `string & MyTag`
This approach allows us to categorize strings as either Relative
or Absolute
paths (not applicable for tagging a number
- refer to the second option for such cases):
declare module Path {
export const enum Relative {}
export const enum Absolute {}
}
type RelativePath = string & Path.Relative;
type AbsolutePath = string & Path.Absolute;
type Path = RelativePath | AbsolutePath
Hence, by casting, we can label any string instance as any form of Path
:
var path = 'thing/here' as Path;
var absolutePath = '/really/rooted' as AbsolutePath;
However, there is no validation during casting, so it's feasible to do:
var assertedAbsolute = 'really/relative' as AbsolutePath;
// compiles without issue, fails at runtime somewhere else
To address this concern, control-flow based type checks can ensure casting only if a test passes (at run time):
function isRelative(path: String): path is RelativePath {
return path.substr(0, 1) !== '/';
}
function isAbsolute(path: String): path is AbsolutePath {
return !isRelative(path);
}
These functions can be utilized to guarantee handling correct types without any run-time errors:
var path = 'thing/here' as Path;
if (isRelative(path)) {
// path's type is now string & Relative
withRelativePath(path);
} else {
// path's type is now string & Absolute
withAbsolutePath(path);
}
Structural Branding of Interfaces / Classes Using Generics
Regrettably, we cannot tag subtypes like Weight
or Velocity
since TypeScript simplifies number & SomeEnum
to just number
. Instead, generics and a field can be used to brand a class or interface and attain comparable nominal-type behavior. This method resembles @JohnWhite's suggestion with private name, but avoids name clashes as long as the generic is an enum
:
/**
* Nominal typing for any TypeScript interface or class.
*
* If T is an enum type, any type embracing this interface
* will only correspond with other types labeled with the same
* enum type.
*/
interface Nominal<T> { 'nominal structural brand': T }
// Alternatively, you can employ an abstract class
// By having the type argument `T extends string`
// rather than `T /* must be enum */`
// collisions can be avoided if you choose a matching string as someone else
abstract class As<T extends string> {
private _nominativeBrand: T;
}
declare module Path {
export const enum Relative {}
export const enum Absolute {}
}
type BasePath<T> = Nominal<T> & string
type RelativePath = BasePath<Path.Relative>
type AbsolutePath = BasePath<Path.Absolute>
type Path = RelativePath | AbsolutePath
// Indicate that this string is some kind of Path
// (Equivalent to using
// var path = 'thing/here' as Path
// which is the function's primary purpose).
function toPath(path: string): Path {
return path as Path;
}
The "constructor" needs to create instances of branded types from the base types:
var path = toPath('thing/here');
// alternatively, a type cast also suffices
var path = 'thing/here' as Path
Furthermore, control-flow based types and functions can enhance compile-time safety:
if (isRelative(path)) {
withRelativePath(path);
} else {
withAbsolutePath(path);
}
Moreover, this technique applies to number
subtypes as well:
declare module Dates {
export const enum Year {}
export const enum Month {}
export const enum Day {}
}
type DatePart<T> = Nominal<T> & number
type Year = DatePart<Dates.Year>
type Month = DatePart<Dates.Month>
type Day = DatePart<Dates.Day>
var ageInYears = 30 as Year;
var ageInDays: Day;
ageInDays = ageInYears;
// Compilation error:
// Type 'Nominal<Month> & number' is not assignable to type 'Nominal<Year> & number'.
Adapted from https://github.com/Microsoft/TypeScript/issues/185#issuecomment-125988288