TypeScript - конкретные типы строк

Я ищу лучший способ различать различные типы строк в моей программе - например, абсолютные пути и относительные пути. Я хочу, чтобы иметь возможность принимать или возвращать определенный тип с ошибкой компилятора, если я его испортил.

Например,

function makeAbsolute(path: RelativePath): AbsolutePath {
}

где AbsolutePath и RelativePath и действительно просто строки. Я экспериментировал с псевдонимами типов, но на самом деле они не создают новый тип. Также интерфейсы -

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

но поскольку эти интерфейсы совместимы, компилятор не мешает мне их смешивать. Я не вижу, как я могу это сделать, не добавляя свойство к интерфейсу, чтобы сделать его несовместимым (и фактически добавив это свойство в строку или кастинг), либо используя класс-оболочку. Любые другие идеи?

Ответ 1

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"

Это просто неправильно на всех уровнях, на которых вы могли бы написать книгу об этом... - но он работает хорошо, и он выполняет свою работу.

Поскольку их создание управляется как таковое, экземпляры AbsolutePath и RelativePath:

  • считаются несовместимыми друг с другом с помощью TS-компилятора (из-за частной собственности)
  • считается (наследуется от) String компилятором TS, позволяя назвать строковые функции
  • фактически реальные строки во время выполнения, обеспечивая поддержку времени выполнения предположительно унаследованных строковых функций

Это аналогично "поддельному наследованию" (поскольку TS-компилятор рассказывается о наследовании, но это наследование не существует во время выполнения) с дополнительной проверкой данных. Поскольку никаких открытых участников или методов не было добавлено, это никогда не должно вызывать неожиданного поведения во время выполнения, поскольку такая же предполагаемая функциональность существует как во время компиляции, так и во время выполнения.

Ответ 2

Есть несколько способов сделать это. Все они включают "пометку" целевого типа с использованием пересечений.

Маркировка enum

Мы можем использовать тот факт, что существует один номинальный тип в TypeScript - тип Enum, чтобы различать иначе структурно идентичные типы

Тип перечисления представляет собой отдельный подтип примитивного типа Number

Что это значит?

Интерфейсы и классы сравниваются структурно

interface First {}
interface Second {}

var x: First;
var y: Second;
x = y; // Compiles because First and Second are structurally equivalent

. Перечисления различаются в зависимости от их "идентичности" (например, они номинируются)

const enum First {}
const enum Second {}

var x: First;
var y: Second;
x = y;  // Compilation error: Type 'Second' is not assignable to type 'First'.

Мы можем использовать номинальную типизацию Enum для "маркировки" или "маркировки" наших структурных типов одним из двух способов:

Типы меток с перечисляемыми типами

Так как TypeScript поддерживает типы пересечений и псевдонимы типов, мы можем "пометить" любой тип перечислением и пометить его как новый тип. Затем мы можем исключить любой экземпляр базового типа в "помеченный" тип:

const enum MyTag {}
type SpecialString = string & MyTag;
var x = 'I am special' as SpecialString;
// The type of x is `string & MyTag`

Мы можем использовать это поведение для строк "тегов" как пути Relative или Absolute (это не сработает, если мы хотим пометить number - см. второй вариант для обработки этих случаев)

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

Затем мы можем "пометить" любой экземпляр строки как любой тип Path, просто произведя ее:

var path = 'thing/here' as Path;
var absolutePath = '/really/rooted' as AbsolutePath;

Тем не менее, там нет проверки, когда мы делаем так, чтобы можно было:

var assertedAbsolute = 'really/relative' as AbsolutePath;
// compiles without issue, fails at runtime somewhere else

Чтобы смягчить эту проблему, мы можем использовать проверки типа на основе потока управления, чтобы гарантировать, что мы выполняем только при проверке теста (во время выполнения):

function isRelative(path: String): path is RelativePath {
  return path.substr(0, 1) !== '/';
}

function isAbsolute(path: String): path is AbsolutePath {
  return !isRelative(path);
}

И затем используйте их для обеспечения того, чтобы мы обрабатывали правильные типы без каких-либо ошибок во время выполнения:

var path = 'thing/here' as Path;
if (isRelative(path)) {
  // path type is now string & Relative
  withRelativePath(path);
} else {
  // path type is now string & Absolute
  withAbsolutePath(path);
}

Общий структурный "брендинг" интерфейсов/классов

К сожалению, мы не можем пометить подтипы number, такие как Weight или Velocity, потому что TypeScript достаточно умен, чтобы уменьшить number & SomeEnum до number. Мы можем использовать generics и поле для "маркировки" класса или интерфейса и получения аналогичного поведения по типу. Это похоже на то, что @JohnWhite предлагает своим личным именем, но без возможности столкновения имен, если общий тип 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;
}

Мы должны использовать наш "конструктор" для создания экземпляров наших "фирменных" типов из базовых типов:

var path = toPath('thing/here');
// or a type cast will also do the trick
var path = 'thing/here' as Path

И снова мы можем использовать типы и функции на основе потока управления для дополнительной безопасности во время компиляции:

if (isRelative(path)) {
  withRelativePath(path);
} else {
  withAbsolutePath(path);
}

И, как дополнительный бонус, это также работает под number подтипами:

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'.

Адаптировано из https://github.com/Microsoft/TypeScript/issues/185#issuecomment-125988288