Токены

В разработке слабосвязанной архитектуры с динамическим управлением зависимостями возникает необходимость совмещать неопределённость (когда типы и ключи заранее неизвестны) со строгой типизацией (когда для каждого конкретного ключа нужно гарантировать определённый тип возвращаемого значения).

Например, метод контейнера экземпляров (DI-контейра) function get(key: string): unknown может вернуть результат любого типа, точнее — заранее неизвестного типа unknown. Хотя при передаче конкретного значения key в аргументах метода, в ответе метода ожидается вполне конкретный результат (тип возвращаемого экземпляра). Поэтому на уровне типов нужно строго описать связь ключа с результатом метода и не использовать unknown или any.

Если контейнер был бы жестко связан с используемыми модулями (напрямую их импортировал), то можно было бы применить карту типов — сопоставление названий модулей с типами их фабричных функций. В таком случаи тип для результата метода get() можно определить по ключу. Кроме того, тип для ключей сужается до реально существующих названий модулей, как показано ниже:

// Все импортиованные модули (фабричные функции)
type Modules = typeof modules; 
// Карта типов - типы модулей (фабричный функций) по названиям модулей.
type Results = {
  [Name in keyof Modules]: ReturnType<Modules[Name]>
}

// Типизация с картой типов
function get<Name extends keyof Modules>(key: Name): Results[Name]

Однако контейнер избавлен от жёстких связей и не знает о всех возможных модулях — их названиях и типах, так как не импортирует их. В такой ситуации стоит переосмыслить подход к типизации: система типов должна быть слабосвязанной как и модули проекта.

Токен-строка

Решить задачу поможет TypeScript с приёмом "извлечение типа". В примере выше уже применяется дженерик для типизации метода get(key), только тип результата определяется через доступ по индексу [Name] из конкретного типа Results. Нужно же абстрагироваться от конкретики и забыть про существование типа Results.

Если строковый ключ key как-то связать с типом возвращаемого значения, то можно попробовать извлекать тип результата из типа ключа. Этого можно достичь, применив дженерик для типизации строковых ключей — через параметр Type в дженерике Key<Type> упаковать нужный тип в типе ключа...

// Тип для ключей (todo: нужно доделать так как параметр Type не применен)
type Key<Type> = string;

// Пример типизации метода get извлечением Type из Key<Type>
function get<Type>(key: Key<Type>): Type

Подставляя конкретный ключ в вызов метода get(), TypeScript автоматически выведет конкретный тип из ключа для результата метода. Но код выше пока нерабочий, так как дженерик Key<Type> не описан полностью. Необходимо в объявлении параметр Type  с чем-то связать, но пока непонятно с чем, так как для ключа используется скалярный тип string.

Можно пойти на хитрость и объединить тип string с типом простого объекта из одного поля _. Объединение будет валидным, так как строка ведет себя как объект. Назвать поле _ можно иначе, а его объединение со string нужно только для определения дженерика, чтобы параметр Type привязать к полю _.

type Key<Type> = string & { _: Type };

В реальности к строковому значению никакого поля добавляться не будет (оно существует только в типе), тем более невозможно присвоить новое поле к примитивному значению. Достаточно того, что TypeScript сможет выводить тип из ключа.

Чтобы создать непосредственно строковый ключ, связанный с конкретным типом, придётся применять приведение типов. В дженерике Key<Type> на месте параметра Type нужно указывать связываемый тип.

const SOME = 'some' as Key<SomeModule>;

В примере выше ключ SOME связан с типом SomeModule. Для придания ясности коду и сокрытия приведения типов можно реализовать вспомогательную функцию создания типизированных ключей:

function newKey<Type>(uniqueName: string): Key<Type> {
  return uniqueName as Key<Type>;
}

Ключ всегда создаётся с явным указанием связываемого типа в угловых скобках. И такой ключ называют токеном:

const SOME_TOKEN = newKey<SomeModule>('some');

Токен (ключ) SOME_TOKEN будет типизированным, а связанный с ним тип можно извлекать везде, где потребуется. Токен применяется в контейнере в методе get() — тип для результата метода будет выведен автоматически из переданного токена SOME_TOKEN.

const some: SomeModule = container.get(SOME_TOKEN);
Токены в провайдерах

Благодаря применению токенов контейнеру не нужно знать, какие именно экземпляры в нём хранятся. Строгий контроль типов выполняется во всех методах контейнера, а внутренние поля могут быть объявлены с типом unknown. Провайдеры, регистрируемые в контейнере тоже будут описаны с помощью токенов.

const httpClientProvider = {
  token: HTTP_CLIENT_TOKEN,
  factory: ({ baseUrl, logger }) => createHttpClient({ baseUrl, logger }),
  depends: {
    baseUrl: BASE_URL_TOKEN,
    logger: LOGGER_TOKEN
  }
} 

Но чтобы и сам провайдер был описан корректно, то есть чтобы контролировалась сопоставимость указанного токена token с результатом фабричной функции factory, а так же аргументы фабричной функции { baseUrl, logger } с указанными зависимостями depends, необходимо применять вспомогательную функцию на дженериках.

interface Provider<Type = any, ExtType extends Type, DepsTokens> {
  token: Token<Type>;
  factory: FunctionWithArgs<ExtType, TypesFromTokens<DepsTokens>>;  
  depends: DepsTokens;  
}

function factoryProvider<Type, ExtType extends Type, DepsTokens>(  
  provider: Provider<Type, ExtType, DepsTokens>,  
): Provider<Type, ExtType, DepsTokens> {  
  return provider;  
}

Функция factoryProvider ничего не делает — возвращает тот же объект provider, что передан. Но за счёт типизации будут работать подсказки в коде, автоматический вывод типов при объявлении провайдера и соблюдаться типобезопасность!

Дженерик TypesFromTokens извлекает карту типов из карты токенов DepsTokens. Она в свою очередь используется для описания типов аргументов фабричной функции с помощью дженерика FunctionWithArgs. В FunctionWithArgs передаётся параметр-тип ExtType, а не Type, чтобы позволить фабричной функции возвращать значение расширенного типа.

// Дженерик карты типов из карты токенов Т
type TypesFromTokens<TokenMap> = {  
  [P in keyof TokenMap]: TokenMap[P] extends Token<infer Type> ? Type : undefined;  
};

// Дженерик фабричной функции, чтобы подставлять конкретные типы результата и аргументов
type FunctionWithArgs<Result, Args extends Record<string, any>> = (depends: Args) => Result;

Теперь если указать фабричную функцию с несопоставимым типом, отобразится ошибка типов:

const SOME_TOKEN = newKey<SomeModule>('some');

const httpClientProvider = {
  token: SOME_TOKEN,
  factory: ({ baseUrl, logger }) => ==createHttpClient==({ baseUrl, logger }),
  depends: {
    baseUrl: BASE_URL_TOKEN,
    logger: LOGGER_TOKEN
  }
} 
TS2322: Type HttpClient is not assignable to type SomeModule

Токен-объект

Токен — это уникальный идентификатор, который может содержать полезную информацию. При этом полезной информацией может быть не только тип (который привязан за счёт TypeScript и в самом токене на самом деле не хранится). В токене можно хранить реальные значения, расширяющие возможности токена для управления зависимостями, например, влияя на жизненный цикл зависимостей. Пока что углубляться в детали не требуется, но интересно задуматься о том, как хранить информацию в токене.

Дополнительную информацию можно кодировать непосредственно в строке токена, применяя URI-формат, JSON... Хотя удобнее токен превратить в объект, сохранив уникальный строковый ключ во внутреннее поле key. К нему всегда можно будет обратиться.

class Token<Type = unknown> {  
  readonly key: Key<Type>; // Ключ токена

  constructor(key: string) {  
    this.key = key as Key<Type>;  
  }  
}

Так как токен описан классом, то для его создания используется оператор new. Хотя можно обернуть в функцию и получить утилиту для создания токенов в функциональном стиле, как ранее было сделано для ключа.

function newToken<Type>(key: string): Token<Type> {  
  return new Token<Type>(key);  
}

Объект сам по себе является уникальным за счёт уникальности ссылки на память. Тогда могут возникнуть сомнение в необходимости строкового ключа внутри объекта-токена. Ведь ключ окажется просто описанием токена? Но это не так.

Токены нужно сравнивать по внутреннему ключу и удобней именно строковому! Уникальность на основе строкового ключа позволяет воссоздать токен в распределённой или многопоточной среде. Токен со строковым ключом можно передать по любому протоколу и восстановить на другой стороне распределённой системы. Токен должен быть воспроизводимым и сериализуемым. По той же причине не используется Symbol вместо строки, так как его поведение похоже на указатель в памяти.

Сам по себе токен используется как некий контейнер или коробочка для передачи уникального значения со связанным типом, дополнительной полезной информацией и вспомогательными методами. Одним из вспомогательных методов может быть метод сравнения токенов по ключу — isEqual():

class Token<Type = unknown> {  
  readonly key: Key<Type>;

  constructor(key: string) {  
    this.key = key as Key<Type>;  
  }  
  
  isEqual(token: Token): boolean {  
    return this.key === token.key;  
  }   
}

Токены с одинаковым названием будут эквивалентны. Из этого следует, что зная уникальное название токена, можно воссоздать токен и новый токен будет эквивалентен существующему с тем же названием.

const TOKEN_1 = newToken<Some>('some');
const TOKEN_2 = newToken<Some>('some');

console.log('Токены равны?', TOKEN_1.isEqual(TOKEN_2))

Токен-декоратор (опциональный токен)

Дополнительные поля в классе Token сейчас не определены, так как пока нет понимания, для каких целей они нужны. Но в будущем токены всё-таки потребуется расширять, например, для указания опциональной зависимости или множественности значения. Но это лучше делать не через изменение базового класса Token, а за счёт определения новых классов, наследуя Token. Хотя на деле задача окажется интересней, если новые поля нужно добавить к уже существующему токену не модифицируя оригинальный токен.

Например есть токен SOME_TOKEN типа Token<SomeModule>. Нужно воспользоваться этим токеном для выборки данных допускающих неопределённость undefined. То есть нужен токен с типом SomeModule|undefined, но с ключом как у SOME_TOKEN. Не проблема — ведь можно создать новый токен с таким же ключом.

const SOME_TOKEN = newToken<SomeModule>('some');
const SOME_TOKEN_OPTIONAL = newToken<SomeModule|undefined>('some');

Есть одно но. Необходимо не только привязать undefined к типу, но и в самом токене обозначить "опциональность" неким признаком, чтобы в логике контейнера учитывать его и предотвратить выброс исключения, если по токену не будет найден экземпляр.

    // Метод контейенра (di-контейнера). 
    // Выборка экземпляра модуля по ключу, напрмиер "httpClient"
    get<Type>(token: Token<Type>): Type {  
      if (!providers.has(key)) {
          if (token.is('optional')) {  // <-- В токене есть признак, чтобы понять его опционльность в реальтайме
            return undefined as Type;  
          } else {  
            throw Error(`Provider by token "${token}" not registered`);  
         }
      }
      if (!instances.has(key)) {
        //...создание экземпляра
      }
      return instances.get(key);  
    },  

Для решения задачи применяется паттерн проектирования "Декоратор". Определяется новый класс TokenDecorator но не наследованием класса Token, а реализацией того же интерфейса как у токена. В классе декоратора реализуются те же методы токена, но декоратор не хранит уникальный ключ токена. Новый класс не для создания новых токенов. В декораторе будет храниться ссылка на оригинальный токен, и почти во всех методах будет вызов методов оригинального токена. Единственный метод, который реализуется в декораторе — проверка признака. В оригинальном классе Token, метод is() тоже определён, но всегда возвращает false.

// Декоратор на токен, чтобы наделять токен признаками
class TokenDecorator<Type = any> implements TokenInterface<Type> {  
  constructor(  
    protected token: TokenInterface<Type>,  
    protected attributes: Record<string, boolean>,  
  ) {}  
  
  get key(): TokenKey<Type> {  
    return this.token.key;  
  }  
   
  isEqual(token: TokenInterface): boolean {  
    return this.token.isEqual(token);  
  }  
  
  is(attribute: string): boolean {  
    if (attribute in this.attributes) return this.attributes[attribute];  
    return this.token.is(attribute);  
  }  
}

В итоге получаем возможность декорировать токен любыми признаками. Остаётся только под конкретную задачу подготовить удобную утилиту декорирования.

В определении зависимостей однозначно потребуются опциональные токены, да и исходный код в контейнере уже приведён с учётом признака is('optional'), поэтому имеется утилита для создания опционального токена на основе оригинального:

function optional<Type>(token: TokenInterface<Type>): TokenDecorator<Type|undefined> {  
  return new TokenDecorator(token, { optional: true });  
}

Опциональный токен теперь создаётся через обертку оригинального. Оригинальный токен не изменяется. Декоратор добавляется признак, а утилита optional выполняет приведение типа из TokenInterface<Type> в TokenDecorator<Type|undefined>. Так как TokenDecorator реализует интерфейс TokenInterface, то будет восприниматься контейнером как обычный токен.

const SOME_TOKEN = newToken<SomeModule>('some');
const SOME_TOKEN_OPTIONAL = optional(SOME_TOKEN);

Опциональные токены обычно используются в провайдерах для описания необязательных зависимостей в depends.

const httpClientProvider = {
  token: HTTP_CLIENT_TOKEN,
  factory: ({ baseUrl, logger, some }) => createHttpClient({ baseUrl, logger, some }), // some может быть undefined
  depends: {
    baseUrl: BASE_URL_TOKEN,
    logger: LOGGER_TOKEN,
    some: optional(SOME_TOKEN)
  }
} 

Ещё дополнительный признак в токене может понадобиться для обозначения "множественных экземпляров". Когда из контейнера по указанному токену нужно всегда возвращать новый экземпляр, а не один общий. Но это задача узкоспециализированная, и к ней можно вернуться в отдельной теме.

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

Содержание