Типы расширений в TypeScript
Создавая библиотеку TypeScript, вы неизбежно столкнетесь со сценарием, когда кто-то захочет ее расширить. В мире JavaScript это можно сделать с помощью следующего хака:
const yourLibrary = require('your-library');
const myExtension = require('./my-extension');
yourLibrary.yourObject.myExtension = myExtension
В TypeScript этот тип вещей обычно не одобряется, потому что система типов не разрешает "манкипатчинг". «Классический» обходной путь - использовать приведение к типу any
, которое эффективно de-TypeScript-ifies TypeScript.
import { yourObject } from 'your-library';
import myExtension from 'my-extension';
(<any>yourObject).myExtension = myExtension
Другая проблема с этим шаблоном заключается в том, что myExtension
не может ссылаться на yourObject
, что также требует приведения типов при каждом доступе.
const result = (<any>yourObject).myExtension.myFunction();
На данный момент мы потеряли тип-безопасность myExtension
. Таким образом, компилятор TypeScript больше не будет проверять, действительно ли myFunction
существует в myExtension
, не говоря уже о том, каков результат myFunction()
. Слишком большая часть этого шаблона сделает ваш проект TypeScript непригодным для печати, и тогда JavaScript был бы лучшим вариантом.
Что делать?
Одно из решений - просто сделать запрос на перенос в исходную библиотеку, чтобы ваше расширение было включено в официальный пакет. Хотя в некоторых сценариях это может быть хорошей идеей, в большинстве случаев расширения слишком нишевые, сломанные или слишком большие, чтобы их можно было объединить в проект. Вдобавок ко всему, пул-реквесты часто требуют много времени, чтобы пройти проверку и включить в новый выпуск.
Другое решение, за которое выступает эта статья, - это создание библиотек с безопасными типами расширений. Я понимаю, что это не решит вашу проблему здесь и сейчас, но если вы являетесь автором библиотеки, это даст другим разработчикам простой способ расширить вашу работу, не затрагивая основной пакет. Если вы являетесь потребителем библиотеки, запрос на вытягивание, предоставляющий ей расширенные свойства, обычно намного проще, чем расширение библиотеки с помощью вашей конкретной функции.
Два типа расширений
Наиболее распространенными типами расширений, которые нужны разработчикам, являются расширения пересечения и расширения объединения. Расширения пересечения говорят: «Эй, ваши объекты потрясающие, но они были бы еще более потрясающими, если бы они использовали X». Расширения объединения говорят: «Эй, ваши объекты потрясающие, но вы упускаете несколько из них, которые мне понадобятся для сценария Y». Пересечения и объединения являются частью основного языка TypeScript - операторы пересечения &
и объединения |
являются основным способом создания составных типов. Я выступаю за использование этих операторов для расширения возможностей ваших библиотек.
Расширения пересечения
Расширения пересечения могут быть достигнуты с помощью универсального типа (назовем его U
), который передается вниз через ваши объекты и пересекается с примитивными объектами через оператор &
.
Представим, что ваша библиотека содержит следующие два типа.
type Person = {
name: string;
address?: Address;
friends?: Person[];
}
type Address = {
city: string;
country: string;
}
Расширения пересечений добавляют пересечение ко всем соответствующим типам.
type Person<U> = {
name: string;
address?: Address<U>;
friends?: Person<U>[];
} & U;
type Address<U> = {
city: string;
country: string;
} & U;
Например, если мы хотим добавить необязательный параметр id
ко всем типам, это становится простой операцией.
const me: Person<{id?: number}> = {
name: 'Mike',
address: {
id: 5,
city: 'Helsinki',
country: 'Finland'
},
friends: [{ name: 'Marie', id: 101 }]
}
Более того, теперь у нас есть типизированный метод доступа id
, поэтому следующая функция передаст компилятору TypeScript
const hasId = (p: Person<{id?: number}>) => typeof p.id === 'number';
Расширения обьединения
Представим себе другой сценарий - мы создаем типы для объектов JSON.
type JSONPrimitive = number | boolean | string | null;
type JSONValue = JSONPrimitive | JSONArray | JSONObject;
type JSONObject = { [k: string]: JSONValue; };
interface JSONArray extends Array<JSONValue> {}
Допустим, мы хотели бы, чтобы объекты JavaScript Date
также принимались как JSON. Расширения обьединения, которые я представлю буквой T
, дают нам простой способ сделать это.
type JSONPrimitive<T> = number | boolean | string | null | T;
type JSONValue<T> = JSONPrimitive<T> | JSONArray<T> | JSONObject<T>;
type JSONObject<T> = { [k: string]: JSONValue<T>; };
interface JSONArray<T> extends Array<JSONValue<T>> {}
Теперь мы можем размещать объекты Date
по всему JSON, и компилятор TypeScript не будет жаловаться.
const jsonWithDates: JSONValue<Date> = {
foo: 1,
bar: new Date(),
baz: [true, 'hello', 42, new Date()]
}
Проверка во время выполнения
Если вы используете валидатор типа среды выполнения, например io-ts
, шаблоны очень похожи. Для пересечений мы можем использовать функцию intersection
из io-ts
.
import * as t from 'io-ts';
const PersonValidator = <U>(u: t.TypeOf<U, U>) = t.recursion(
'Person',
t.intersection([
t.type({name: t.string}),
t.partial({
address: AddressValidator(u),
friends: t.array(PersonValidator(u))
}),
u
]));
const AddressValidator = <U>(u: t.TypeOf<U, U>) =
t.intersection([
t.type({city: t.string, country: t.string}),
u
]);
Тот же тип шаблона можно использовать для типов объединения - просто передайте валидатор t.union
вместо t.intersection
где это необходимо.
Покажи мне код!
Это стратегия, которую я использовал для построения json-schema-strictly-typed
, которая создает типизированную версию схемы JSON, расширяемую как с помощью расширений пересечения, так и объединения. Таким образом, люди могут добавлять произвольные расширения к объектам в схеме (пересечение) и произвольные новые примитивы схемы (объединение).
С этого уровня универсальности легко экспортировать вспомогательные объекты для "базовых" случаев.. Базовый вариант расширения пересечения - это просто тип, от которого уже исходят все ваши объекты. В приведенном выше примере Person<{}>
и Address<{}>
будут такими, так как пересечение с {}
- это no-op. Для типов объединения расширение по умолчанию может расширять библиотеку с помощью типа, который уже существует в объединении. Так, например, JSONSchemaValue<string>
не работает.
Я с нетерпением жду возможности увидеть, приживется ли этот шаблон и сможет ли сообщество придумать инструменты, которые помогут поддерживать и создавать библиотеки TypeScript с учетом расширяемости!