I\'m looking for a better way to distinguish between different types of strings in my program- for example, absolute paths and relative paths. I want to be able to have func
There are several ways to do this. All of them involve "tagging" the target type using intersections.
We can leverage the fact that there is one nominal type in TypeScript - the Enum type to distinguish otherwise structurally identical types:
An enum type is a distinct subtype of the Number primitive type
Interfaces and classes are compared structurally
interface First {}
interface Second {}
var x: First;
var y: Second;
x = y; // Compiles because First and Second are structurally equivalent
Enums are distinct 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 take advantage of Enum
's nominal typing to "tag" or "brand" our structural types in one of two ways:
Since Typescript supports intersection types and type aliases we can "tag" any type with an enum and mark that as a new type. We can then cast any instance of the base type to the "tagged" type without issue:
const enum MyTag {}
type SpecialString = string & MyTag;
var x = 'I am special' as SpecialString;
// The type of x is `string & MyTag`
We can use this behavior to "tag" strings as being Relative
or Absolute
paths (this wouldn't work if we wanted to tag a number
- see the second option for how to handle those 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
We can then "tag" any instance of a string as any kind of Path
simply by casting it:
var path = 'thing/here' as Path;
var absolutePath = '/really/rooted' as AbsolutePath;
However, there's no check in place when we cast so it's possible to:
var assertedAbsolute = 'really/relative' as AbsolutePath;
// compiles without issue, fails at runtime somewhere else
To mitigate this issue we can use control-flow based type checks to ensure that we only cast 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);
}
And then use them to ensure that we're handling the 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);
}
Unfortunately we cannot tag number
subtypes like Weight
or Velocity
because Typescript is smart enough to reduce number & SomeEnum
to just number
. We can use generics and a field to "brand" a class or interface and get similar nominal-type behavior. This is similar to what @JohnWhite suggests with his private name, but without the possibility of name collisions as long as the generic is an enum
:
/**
* Nominal typing for any TypeScript interface or class.
*
* If T is an enum type, any type which includes this interface
* will only match other types that are tagged with the same
* enum type.
*/
interface Nominal { 'nominal structural brand': T }
// Alternatively, you can use an abstract class
// If you make the type argument `T extends string`
// instead of `T /* must be enum */`
// then you can avoid the need for enums, at the cost of
// collisions if you choose the same string as someone else
abstract class As {
private _nominativeBrand: T;
}
declare module Path {
export const enum Relative {}
export const enum Absolute {}
}
type BasePath = Nominal & string
type RelativePath = BasePath
type AbsolutePath = BasePath
type Path = RelativePath | AbsolutePath
// Mark that this string is a Path of some kind
// (The alternative is to use
// var path = 'thing/here' as Path
// which is all this function does).
function toPath(path: string): Path {
return path as Path;
}
We have to use our "constructor" to create instances of our "branded" types from the base types:
var path = toPath('thing/here');
// or a type cast will also do the trick
var path = 'thing/here' as Path
And again, we can use control-flow based types and functions for additional compile-time safety:
if (isRelative(path)) {
withRelativePath(path);
} else {
withAbsolutePath(path);
}
And, as an added bonus, this also works for number
subtypes:
declare module Dates {
export const enum Year {}
export const enum Month {}
export const enum Day {}
}
type DatePart = Nominal & number
type Year = DatePart
type Month = DatePart
type Day = DatePart
var ageInYears = 30 as Year;
var ageInDays: Day;
ageInDays = ageInYears;
// Compilation error:
// Type 'Nominal & number' is not assignable to type 'Nominal & number'.
Adapted from https://github.com/Microsoft/TypeScript/issues/185#issuecomment-125988288