Внутри модуля можно создавать интерфейсы TypeScript, которые нельзя расширить за пределы модуля. Это может быть полезно в рекурсивных универсальных типах, чтобы отличить входные параметры от параметров, используемых только для рекурсии. В этой статье подробно рассказывается, почему это желательно и как этого можно добиться.

Обзор

Я работаю над библиотекой Utility-Types с открытым исходным кодом для TypeScript. Я работаю над своим первым сольным продуктом, который решает распространенную проблему в опыте разработчиков. Не раскрывая слишком много, одной из наиболее важных особенностей продукта является тесная интеграция между разработанными мной средствами автоматизации и системой типов TypeScript. Для этого мне нужно было создать надежный набор типов, который помог бы преодолеть разрыв между системой типов TypeScript и кодом времени выполнения.

В последнее время я писал МНОГО типов TypeScript, и мне нужна была возможность скрыть детали реализации некоторых рекурсивных типов. Вот пример.

/**
 * Create a tuple of length `A` with entries of type `B` (defaults to `any`)
 * @example
 * ```
 * type Ex1 = AnyTuple<2> // [any, any]
 * type Ex2 = AnyTuple<3, number> // [number, number, number]
 * ```
 */
export type AnyTuple<
  A extends number,
  B = any,
  C extends AnyArray = []
> = C extends {
  length: A
}
  ? C
  : AnyTuple<A, B, [...C, B]>

В этом примере тип принимает число A и тип B и создает кортеж C, добавляя B до тех пор, пока длина C не станет равной A. Эта реализация довольно ясна, однако кто-то, глядя на этот исходный код, знает, что они не нужно (и не следует) указывать третий параметр? Ответ — нет… Посмотрим, сможем ли мы это исправить.

interface Opts<A extends AnyArray = AnyArray> extends Options<'AnyTuple'> {
  value: A
}

/**
 * Create a tuple of length `A` with entries of type `B` (defaults to `any`)
 * @group Any
 * @example
 * ```
 * type Ex1 = AnyTuple<2>         // [any, any]
 * type Ex2 = AnyTuple<3, number> // [number, number, number]
 * ```
 */
export type AnyTuple<
  A extends number,
  B = any,
  Z extends Opts = Opts<[]>
> = LengthProp<Z['value']> extends A
  ? Z['value']
  : AnyTuple<A, B, Opts<[...Z['value'], B]>>

В обновленном примере я создал интерфейс для дополнительного параметра универсального типа. Интерфейс Opts расширяет другой интерфейс под названием Options. Особым свойством интерфейса Options является невозможность создания экземпляра интерфейса Options вне библиотеки.

Интерфейс параметров

Интерфейс параметров довольно прост. Он использует одну из особенностей TypeScript. В TypeScript есть несколько мест, где грань между системой типов и кодом времени выполнения становится размытой. Одним из таких примеров является прямая ссылка на символ как на ключ объекта или класса.

Каждый вызов Symbol() создает уникальный символ. Поэтому, если мы создадим интерфейс, который использует уникальный символ, у нас будет отправная точка для интерфейса, который нашему пользователю будет трудно создать. Идеальное место, чтобы скрыть детали реализации рекурсивных типов!

export const TYPE_NAME = Symbol()

export interface Options<A extends string = string> {
  [TYPE_NAME]: Capitalize<A>
}

Теперь следует сказать, что в этом подходе есть одна ошибка. Мы не можем экспортировать ни уникальный символ, используемый в интерфейсе параметров, ни любые интерфейсы, которые его расширяют. Это даст пользователям доступ к уникальному символу либо в системе типов, либо в коде времени выполнения. Если бы у них был доступ к символу, они могли бы создавать свои собственные объекты Options.

Однако, если мы ДЕЙСТВИТЕЛЬНО осторожны и не экспортируем какие-либо реализации параметров, то у нас есть 100% частный интерфейс, который наш пользователь не может создать, гарантируя, что никто не начнет возиться с внутренностями рекурсивного универсального типа. Вероятно, мне следует назвать ее аналогично переменной this из React.

Заключение

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

Я пишу о TypeScript, улучшении опыта разработчиков и иногда о теоретической математике. Обязательно подпишитесь, чтобы узнать больше.

Активная серия