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

Зачем нам конфигурируемая типизация?

Конфигурация исполняемой логики NPM пакетов не является чем-то новым. Например, в Axios можно глобально изменять дефолтные составляющие запросов, а также через интерцепторы глобально изменить логику самих запросов. А MobX, например, предоставляет функцию configure, с помощью которой можно отключить некоторые проверки или немного изменить логику по умолчанию.

import axios from 'axios';
import { configure } from 'mobx';

// Пример глобальной конфигурации запросов в Axios
axios.interceptors.response.use(
  // Из-за того, что в этой функции возвращается не response,
  //  а response.data, структура результата функции меняется. 
  (response) => response.data,
  // А благодаря этому колбэку мы можем вывести сообщения в
  //  консоль при определенных статусах ответа сервера.
  (error) => {
    if (error.response?.status === 401) {
      console.error('User is not authorized');
    }
    if (error.response?.status === 404) {
      console.error('Not found');
    }
    return Promise.reject(error);
  }
);

// Пример конфигурации MobX
configure({
  // Эта настройка убирает проверку корректности обновления
  //  наблюдаемых полей
  enforceActions: 'never',
  // А эта позволяет использовать MobX без использования 
  //  объекта `Proxy`
  useProxies: 'never',
});

Но мы здесь собрались поговорить о конфигурируемой типизации. Что это такое вообще?

В примере выше с axios была изменена структура результата функции запроса. Но TypeScript об этом изменении не узнал. Соответственно, на этапе компиляции могут возникнуть ошибки или хотя бы трудности, если вы таким образом проконфигурируете ваши запросы. Однако, будь у вас возможность настроить типизацию результата функции, подобных проблем бы не возникло.

Еще один пример - декораторы. Если следите за обновлениями TypeScript, вы знаете, что в его мажорной пятой версии появилась новая реализация декораторов - proposal, статья MicroSoft. Но если нет, я вкратце вам расскажу. Декораторы в TypeScript были и ранее, но в другой реализации. И , чтобы  их использовать необходимо было настроить tsconfig.json.

// file.ts
declare const oldExperimentalDecorator: PropertyDecorator;

export class Class {
  @oldExperimentalDecorator
  public property: number = 1;
}

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  },
  // ...
}

Сейчас “старые” декораторы все так же доступны. Однако, если в пятой версии в tsconfig не указывать experimentalDecorators: true, TypeScript будет компилировать декораторы на новый лад. И будет использовать новую типизацию.

declare const oldExperimentalDecorator: PropertyDecorator;
declare const newDecorator: <This, Value>(_: unknown, context: ClassFieldDecoratorContext<This, Value>) => void;

export class Class {
  // If `experimentalDecorators` in tsconfig is false, or not set
  // there will be a type error
  @oldExperimentalDecorator
  public property: number = 1;


  // If `experimentalDecorators` in tsconfig is true there will
  // be a type error
  @newDecorator
  public property1: number = 1;
}

А теперь представьте. Вы создаете NPM пакет, и хотите дать возможность использовать декораторы как в старой реализации, так и в современной. Вы можете прописать логику так, чтобы ваши декораторы работали в обоих случаях, но написать типизацию, чтобы TypeScript правильно анализировал ваш код в обоих случаях у вас не получится. Вы заранее не можете знать, включил ли разработчик конечного продукта опцию experimentalDecorators.

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

Подготовка к погружению

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

Во-первых, в TypeScript возможно “слияние” интерфейсов. Благодаря этому вы можете расширять типизацию, задаваемую при помощи ключевого слова interface. Для это вы просто должны объявить интерфейс с точно таким же именем, что и интерфейс который вы хотите расширить.

interface Interface {
  prop1: number;
}

interface Interface {
  prop2: number;
}

declare const value: Interface;

// Мы имеем доступ и к prop1, и к prop2, т.к. произошло слияние интерфейсов
console.log(value.prop1, value.prop2);

Во-вторых, когда вы скачиваете NPM пакет, его имя - то, что используется в package.json - становится названием модуля. Да-да, если вы не знали, имя для модуля берется именно из этого файла, так что вы можете, например, скачать библиотеку axios, но обозвать ее как угодно.

// package.json
{
  "dependencies": {
    "@types/react": "^16.14.43",
    "@types/react2": "npm:@types/react@18",
    "axios": "^1.4.0",
    "axixixios": "npm:axios@1.4.0",
    "react": "^16.14.0",
    "react2": "npm:react@18"
  }
}

// file.ts
// И теперь мы можем использовать модули ‘react’, ‘react2’, ‘axios’ и ‘axixixios’
import axios from 'axios';
import axixixios from 'axixixios';
import React from 'react';
import React2 from 'react2';

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

import { FunctionComponent } from 'react';

declare module 'react' {
  interface FunctionComponent {
    newStaticProperty: number;
  }
}

declare const Component: FunctionComponent;

// Ошибок типизации нет, интерфейсы были объеденены
console.log(Component.newStaticProperty);

Как настроить конфигурируемую типизацию?

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

Знаний, описанных выше уже достаточно, чтобы мы могли расширить интерфейс, описываемый во внешнем модуле. В примере с React, мы добавили новое статическое поле в типизацию функциональных компонент.

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

// file1.ts
export interface TypesConfiguration {}

// Type must be set in the final product. | undefined is not necessary
export const value: (TypesConfiguration extends {
  value: unknown
} ? TypesConfiguration[value] : never) | undefined = undefined;

// =====================

// file2.ts
import { value } from './file1';
import { expectType } from 'ts-expect';

declare module './file1' {
  interface TypesConfiguration {
    value: string;
  }
}
// There's no error. The type is `string | undefined`
expectType<string | undefined>(value);
// Error. The type is `string | undefined`
expectType<number | undefined>(value);

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

А также вы можете использовать возможности условной типизации. Благодаря ней вы можете установить фолбэк на менее строгий тип для вашей сущности, если в конечном продукте не объявили более строгую типизацию. Или вы можете как по флагу переключать вашу типизацию из одного состояния в другое.

// file1.ts
export interface TypesConfiguration {}

// Typed by flag. If flag is not used, type is string, otherwise - number. `| undefined` is not necessary
export const valueTypedByFlag: (TypesConfiguration extends {
  useNumber: true
} ? number : string) | undefined = undefined;

// Type must be set in the final product. If the type is not set, a fallback non-strict type is used
export const mapTypedWithFallback: TypesConfiguration extends { mapTypedWithFallback: unknown }
? TypesConfiguration['mapTypedWithFallback']
: Record<string, number> = {};

// =====================

// file2.ts
import { value, map } from './file1';
import { expectType } from 'ts-expect';

declare module './file1' {
  interface TypesConfiguration {
    // If useNumber: true is not passed, the type of `value`
    //  will be string
    useNumber: true;

    // If `map` is not passed, we can use any key in the map.
    //  But now only `key1` and `key2` are accessible
    mapTypedWithFallback: {
      key1: 1,
      key2: 2,
    }
  }
}


// No error since useNumber: true is used
expectType<number | undefiend>(value);

// No error
console.log(map, map.key1, map.key2);

// Error if `mapTypedWithFallback` is passed
console.log(map.randomKey);

Но где это пригодится?

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

Пример с декораторами

Вернемся к примеру с декораторами. Как и с флагом useNumber мы можем сказать разработчику конечной библиотеки, что он может добавить в интерфейс TypesConfiguration флаг, который будет ответственен за переключение типа декораторов.

// file1.ts
export interface TypesConfiguration {}

export const decorator: TypesConfiguration extends { experimentalDecorators: true }
  ? PropertyDecorator
  : <This, Value extends number>(_: unknown, context: ClassFieldDecoratorContext<This, Value>) => void;

// file2.ts
import { decorator } from './file1';

declare module './file1' {
  interface TypesConfiguration {
    // Must be set to true only if it is set to true in the tsconfig file.
    experimentalDecorators: true;
  }
}

class Class {
  @decorator
  property: number;
}

Таким образом, разработчик конечного продукта будет волен сам решать, какую типизацию он хочет использовать на декораторах. Если ему понадобятся исключительно устаревшие декораторы, ему нужно будет в tsconfig переключить флаг experimentalDecorators в true, и затем указать experimentalDecorators: true в TypesConfiguration. В ином случае, в tsconfig и в TypesConfiguration прописывать ничего не потребуется.

Пример с регистрацией и использованием

Еще один пример, который я относительно часто замечаю в NPM пакетах - это применение подхода с регистрацией и использованием определенной сущности по ключу. Для примера возьму библиотеку TSyringe.

import { container } from 'tsyringe';

class A { prop1: number }
class B { prop2: string }

// Так мы регистрируем сущность
container.register('ClassA', A);
container.register('ClassB', B);

// А так её получаем. Типизация при этом происходит по дженерику
const a: A = container.resolve('ClassA');
const b = container.resolve<B>('ClassB');

В функции register передается некоторый ключ, и значение. А в функции resolve по заданному ключу можно получить значение. В TSyringe вы можете типизировать объект, получаемый в функции container.resolve, при помощи дженерика - что я и сделал в примере выше.

Но такой подход не очень безопасен. Во-первых, в функции resolveвы можете передать ключ, которого не существует. Во-вторых, TSyringe никак не контролирует типизацию при использовании этой функции, и при использовании дженерика неправильного типа какой-либо ошибки не произойдет. Строгостью даже не пахнет.

const a: A = container.resolve('ClassA');
// Typed as A, although it is B
const b: A = container.resolve('ClassB');
// Typed as B, although it doesn’t exist
const c: B = container.resolve('Unknown token');

Однако, строгость все-таки добавить можно. Можем однозначно сказать, что по определенному ключу мы всегда должны получать сущность определенного типа.

// file1.ts
export interface TypesConfiguration {}

type KeyToClassMap = TypesConfiguration extends { registry: unknown }
  ? TypesConfiguration['registry']
  : Record<string, any>;

type Constructable<T> = { new (...args: unknown[]): T };

const map: KeyToClassMap = {}

export type TRegister = TypesConfiguration extends { registry: unknown }
  // @ts-ignore
  ? <Key extends keyof TypesConfiguration['registry']>(key: Key, Class: Constructable<TypesConfiguration['registry'][Key]>) => void
  : (key: string, Class: Constructable<any>) => void;

export const register: TRegister = (key, Class) => {
  map[key] = new Class();
};

export type TResolve = TypesConfiguration extends { registry: unknown }
  // @ts-ignore
  ? <T extends keyof TypesConfiguration['registry']>(key: T) => TypesConfiguration['registry'][T]
  : <T>(key: string) => T;

export const resolve: TResolve = (key) => map[key];

// =====================

// file2.ts
import { resolve, register } from './file1';

declare module './file1' {
  interface TypesConfiguration {
    // If we define registry here, our types become much stricter
    registry: {
      ClassA: A,
      ClassB: B,
    }
  }
}

class A { prop1: number }
class B { prop2: string }


// Can register only `A`
register('ClassA', A);
// Can register only `B`
register('ClassB', B);


// Can return only type A
const a = resolve('ClassA');
// Can return only B. Which is why `: A` type will cause an error
const b: A = resolve('ClassB');
// Unknown key will cause an error
const c = resolve('Unknown token');

В таком случае наша типизация получается максимально строгой. Разработчик не сможет использовать функцию register с новым ключом, пока не дополнит TypesConfiguration['registry']. Но так же он не сможет использовать функцию resolve с ключом, которого не существует, или неправильно протипизировать объект, возвращаемый в функции.

Нужно ли в вашем пакете использовать конфигурируемую типизацию?

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

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

Конец

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

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

Ещё раз приложу ссылку на репозиторий с полноценными примерами конфигурации типов в NPM пакетах с юнит-тестами на типы. И на этом все. Пока.

Комментарии (0)