В разработке слабосвязанной архитектуры с динамическим управлением зависимостями возникает необходимость совмещать неопределённость (когда типы и ключи заранее неизвестны) со строгой типизацией (когда для каждого конкретного ключа нужно гарантировать определённый тип возвращаемого значения).
Например, метод контейнера экземпляров (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)
}
}
Ещё дополнительный признак в токене может понадобиться для обозначения "множественных экземпляров". Когда из контейнера по указанному токену нужно всегда возвращать новый экземпляр, а не один общий. Но это задача узкоспециализированная, и к ней можно вернуться в отдельной теме.
С помощью токенов решен вопрос строгой типизации в слабосвязанной архитектуре. В следующих темах рассмотрим разные сценарии управления зависимостями между модулями проекта.