Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript - specific string types

Tags:

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 functions take or return a certain type with a compiler error if I mess it up.

For example,

function makeAbsolute(path: RelativePath): AbsolutePath {
}

where AbsolutePath and RelativePath and really just strings. I experimented with type aliases, but those don't actually create a new type. Also interfaces -

interface AbsolutePath extends String { }
interface RelativePath extends String { }

but since those interfaces are compatible, the compiler doesn't prevent me from mixing them up. I don't see how I can do this without either adding a property to the interface to make them not compatible (and either actually adding that property to the string, or casting around it), or using a wrapper class. Any other ideas?

like image 731
Rob Lourens Avatar asked May 05 '16 14:05

Rob Lourens


People also ask

How do you define a string type in TypeScript?

It is a type of primitive data type that is used to store text data. The string values are used between single quotation marks or double quotation marks, and also array of characters works same as a string. TypeScript string work with the series of character. var var_name = new String(string);

Are strings reference types in TypeScript?

string is a special reference type that acts like a value type in equality operator. When a string is passed to a function, its reference is passed to the function not a copy of its value.

What is @types in TypeScript?

What is a type in TypeScript. In TypeScript, a type is a convenient way to refer to the different properties and functions that a value has. A value is anything that you can assign to a variable e.g., a number, a string, an array, an object, and a function. See the following value: 'Hello'

What is the type of a string literal?

A string literal contains a sequence of characters or escape sequences enclosed in double quotation mark symbols. A string literal with the prefix L is a wide string literal. A string literal without the prefix L is an ordinary or narrow string literal. The type of narrow string literal is array of char .


2 Answers

There are several ways to do this. All of them involve "tagging" the target type using intersections.

Enum tagging

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

What does this mean?

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:

Tagging types with enum types

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);
}

Generic structural "branding" of interfaces / classes

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<T> { '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<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

// 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<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

like image 89
Sean Vieira Avatar answered Sep 20 '22 22:09

Sean Vieira


abstract class RelativePath extends String {
    public static createFromString(url: string): RelativePath {
        // validate if 'url' is indeed a relative path
        // for example, if it does not begin with '/'
        // ...
        return url as any;
    }

    private __relativePathFlag;
}

abstract class AbsolutePath extends String {
    public static createFromString(url: string): AbsolutePath {
        // validate if 'url' is indeed an absolute path
        // for example, if it begins with '/'
        // ...
        return url as any;
    }

    private __absolutePathFlag;
}
var path1 = RelativePath.createFromString("relative/path");
var path2 = AbsolutePath.createFromString("/absolute/path");

// Compile error: type 'AbsolutePath' is not assignable to type 'RelativePath'
path1 = path2;

console.log(typeof path1); // "string"
console.log(typeof path2); // "string"
console.log(path1.toUpperCase()); // "RELATIVE/PATH"

This is just so wrong on every levels you could write a book about it... -- but it does work nicely, and it does get the job done.

Since their creation is controlled as such, AbsolutePath and RelativePath instances are:

  • believed to be incompatible with each other by the TS compiler (because of the private property)
  • believed to be (inherit from) String by the TS compiler, allowing string functions to be called
  • actually real strings at runtime, providing runtime support for the supposedly inherited string functions

This is analogous to a "faked inheritance" (as the TS compiler is told about an inheritance, but that inheritance does not exist at runtime) with additional data validation. Since no public members or methods were added, this should never cause unexpected runtime behavior, as the same supposed functionality exists both during compilation and runtime.

like image 23
John Weisz Avatar answered Sep 21 '22 22:09

John Weisz