Краткое введение о typescript
За несколько лет typesctipt стал мастхэв в современной веб-разработке (по меньшей мере во фронтэнд). Язык типов, работающих поверх javascript, являющегося языком со слабой динамической типизацией со всеми вытекающими (отсутствие достойного тайп-хинтинга в редакторах, строгой проверки в анализаторах кода и прочее), нивелирует практически все недостатки слабой типизации.
Что отличает typescript от языков, которые изначально имели строгую статическую типизацию, как C#, Java, Kotlin и им подобных...? Какие преимущества он дает и почему так быстро обрел популярность?

Поскольку тайпскрипт развивался как независимый язык типов, существующий исключительно до момента транспиляции да еще являющийся подмножеством языка программирования со слабой типизацией, его создатели не постеснялись взять от жизни все дать волю своему воображению и сделать его настолько гибким, насколько это возможно, позволив разработчикам конструировать типы на основе других независимо от рантайм сущностей языка (тут речь не просто про наследование интерфейсов, а про особенности typescript-а, которые отсутствуют в большинстве строго типизированных языков просто по определению (types mapping, объединения, условные типы и так далее...)).
Utility types
В целях содействия построению типов в typescript были включены специальные типы-утилиты, позволяющие выводить на основе существующих типов новые без дублирования кода (если вы не сталкивались с ними ранее, советую перейти по ссылке и ознакомиться прежде, чем продолжить чтение, т.к. дальнейший материал предполагает наличие базового представление о таких типах), и на момент написания этой статьи раздел utility-types на официальном сайте typescript насчитывает 21 тип, который покрывают самые распространенные кейсы. Однако не все, которые бы хотелось.
Библиотеки типов
Даже с таким арсеналом многим новичкам тяжело дается покрытие сложных ситуаций. Наверняка многие сталкивались с ситуацией, когда в силу своего небольшого опыта часами занимались конструированием того или иного сложного типа, вместо того, чтобы непосредственно заниматься разработкой функционала продукта. И вот как раз на такие случаи и появились пакеты с набором универсальных типов, которые разработчики typescript "доложить" забыли. Эти пакеты - своего рода аналоги lodash в мира typescript, абстрагирующие проверки сложных типов. И ниже мы рассмотрим четыре из них, обсудим их преимущества и недостатки:
ts-toolbelt
Позиционирует себя как крупнейшая библиотека утилит типов TypeScript, содержащая в себе более 200 утилит. В readme все утилиты сгруппированы по категориям, что довольно удобно. Название типов относительно говорящие (иногда однако можно зависнуть...). По клику по названию можно перейти в документацию типа, которая расположена на спец сайте на github page, сгенерированном с помощью eledoc, с кратким описанием утилит и аргументов.
Честно говоря, такая себе документация, хоть и красиво оформленная, поскольку в описании почти нигде (за редким исключением) не приведено примеров их использования (кстати говоря, где они и были приведены, меня встретили беды с подсветкой), которые, как мне кажется, куда нагляднее могли бы рассказать о назначении той или иной утилиты, нежели формальное краткое описание (которое по информативности порой уступает интуитивному нэймингу).
Но... это все лирика. Перейдем к практике и оценим, какие профиты нам дают типы библиотеки:
Flatten
Объявляет тип массива, развертывая в него из переданного ему аргумента все типы вложенных массивов взамен их самих до указанной глубины (по своей сути похож на Array.prototype.flat
в javascript, только про типы):
type Ar = [1, 2, 3, [4, 5]]
type P = Flatten<Ar> // => [1, 2, 3, 4, 5]
В описании сказано, что глубина вложенных массивов ограничена 10-ю (предполагаю потому, что при превышении этого лимита могут появиться шансы напороться на переполнение стэка и положить тс-server).
Compulsory
Некое подобие Required. Но есть несколько отличий:
Во-первых, утилита Compulsory делает все поля объекта не просто обязательными, но еще и еще и необнуляемыми (не знаю, какое слово подобрать на русском, non-nullable - одним словом):
type User = {
name: string,
lastname?: string,
email?: string,
address: {
street: string, number?: number
} | null
}
type UserInfo = Compulsory<User>
/* теперь UserInfo имеет следующий тип:
{
name: string;
lastname: string; // lastname больше не является опциональным
email: string; // email больше не является опциональным
address: { // address больше не является nullable
street: string, number?: number
};
}
*/
Во-вторых, вторым аргументом утилита опционально (если не указать, то обработаны будут все ключи) принимает тип ключей (может быть объединением либо каким-либо более общим типом), которые нужно сделать обязательными:
type UserInfo = Compulsory<User, 'lastname' | 'email'>
/* тип UserInfo будет иметь следующий следующий вид:
{
name: string;
lastname: string; // lastname больше не является опциональным
email: string; // email больше не является опциональным
address: { // а вот address остался nullable!
street: string, number?: number
} | null;
}
*/
Ну и в третьих, Compulsory опциональным третьим аргументом позволяет указать, следует ли рекурсивно обходить вложенные объекты типа ('flat'
- нет, 'deep'
- да, по дефолту - flat
):
type UserInfo = Compulsory<User, string, 'deep'>
/* будет идентично:
{
name: string;
lastname: string; // lastname больше не является опциональным
email: string; // email больше не является опциональным
address: // address больше не является nullable
street: string;
number: number; // number больше не является опциональным
}>;
}*/
Короче говоря, такой себе "Required" на стероидах (более функциональный и гибкий)
Extract
Эта утилита позволяет выбрать (извлечь) диапазон из кортежа (так называют тип массива с фиксированной длинной и позиционным типизированием элементов) - своего рода аналог slice для массива из мира javascript, только для типов.
В использовании довольна прост:
const ar = [1, 2, 3, 4, 5] as const
type R = Extract<typeof ar, 2, 3> // => [3, 4]
// correspond to => ar.slice(2, 4)
В качестве первого аргумента принимает кортеж, в качестве второго - индекс, с которого типы его элементов будут извлечены в новый кортеж, и третьего - индекс, на котором извлечение будет закончено (включительно)
Если вас не устраивает потеря последовательности типов, то утилита может быть очень полезна при использованииArray.prototype.slice
над кортежами. Теперь такое покрытие, не вникая в тонкости математики [на типах], может осуществить даже новичок.
Group
Группирует элементы массива во вложенные с указанным шагом:
type Ar = [1, 2, 3, [4, 5]]
type G = Group<Ar, 2> // => [[1, 2], [3, [4, 5]]]
Утилита интересная, но честно сказать, с первого взгляда не увидел, где могло бы быть часто полезно разбивать тип массива на типы подмассивов таким образом (разве что матрицы какие-то). Но раз в пакете есть - вероятно, кейс нашелся. (если вы когда-либо сталкивались с такой необходимостью - поделитесь пожалуйста в комментариях).
Zip
А идея этой утилиты "перекочевала" в typescript из python (там есть одноименная со схожим функционалом). Она объединяет два типа кортежей в один, состоящий из кортежей, последовательно объединяющих в себе типы элементов исходных массивов:
type Ar = [1, 2, 3, 4]
type Ar2 = [1, 4, 9, 16]
type Z = Zip<Ar, Ar2>
// => [[1, 1], [2, 4], [3, 9], [4, 16]]
Either
Наконец, перед тем, как перейти к следующему пакету, рассмотрим тип Either. В начале обзора я упомянул, что в ts-toolbelt не всегда из именования, аргументов и краткого описания можно понять, что делает тип, а примеров в документации зачастую не встретишь (надеюсь, пофиксят). К слову об именовании, Either c английского переводится как любой, оба, тоже, либо, каждый (в зависимости от контекста, выбирайте сами). Сигнатура типа выглядит так:
Either<O extends object, K extends Key, strict extends Boolean = 1>
В описании сказано, что утилита "разделяет L
на [[Union]]
с K
ключами таким образом, чтобы ни один из ключей никогда не присутствовал друг с другом в разных объединениях". Все понятно? Для меня как-то не очень было. Ну ладно, допустим, мы приняли такое определение. Как вы думаете, какой тип будет выведен из следующей конструкции:
type OO = Either<{ a: 1, b: 1, c: 1 }, 'a'>
// OO -?
ответ: такой,
как и на входе:
{ a: 1; b: 1; c: 1;}
В чем же смысл? Я решил погуглить и наткнулся на issue от 2021 года (значит, похоже, непонятки возникали не только у меня) с примером использования. Оказывалось, что второй аргумент должен быть объединением как минимум двух(!) ключей, чтобы утилита работала. Пример:
type OO = Either<{ a: 1, b: 1, c: 1 }, 'a'|'c'>
/* и на выходе получаем вот такой тип:
{
b: 1;
a: 1;
c?: undefined;
} | {
b: 1;
c: 1;
a?: undefined;
}
*/
И сперва я решил, что цель - сделать из указанных (вторым аргументом) ключей один (любой) ключ опциональным неопределенного типа, т.е. нежелательным (правда, по такой логике, при передаче в качестве второго аргумента только одного ключа (как в первом примере), соответствующее ему поле тоже должно было бы стать нежелательным). Однако, ответ burikella в комментариях убедил меня присмотреться внимательнее:
type OO = Either<{ a: 1, b: 1, aa: 1, c: 1 }, 'a' | 'aa' | 'c'>
/**
type OO = {
b: 1;
a: 1;
c?: undefined;
aa?: undefined;
} | {
b: 1;
c: 1;
a?: undefined;
aa?: undefined;
} | {
b: 1;
aa: 1;
a?: undefined;
c?: undefined;
}
*/
Оказалось, что смысл Either - напротив - в том, чтобы оставить любое из полей, указанных в объединении вторым аргументом, остальные же ключи из этого объединения сделать "нежелательными".
Кстати о термине "нежелательным"... - вроде какой-то туманный, да и не технический вовсе термин. Хотелось бы подобрать более точное слово - например, исключенное или что-то в таком духе. Но возникает вопрос, почему вместо опционального undefined
авторы не решили использовать опциональный never
, чтобы уж наверняка исключить бесполезное пустое поле в объекте?. (Так же, как и почему вторым аргументом принимается K, наследуемая от Key
(что по сути идентично PropertyKey
), а не от `keyof O` (так для второго аргумента хотя бы тайп-хинтинг заработал, не говоря уже об исключении попадания в объединение левых ключей).... Возможно, не баг, а фича. Но остается легкое впечатление, что тип не дополирован).
В любом случае сама задумка полезная. Как ее использовать: принять такой, какая она есть, или немного дополировать под себя - каждый решит за себя сам (авторы кстати - вроде - вполне открыты к контрибьюшену...). Ну а мы переходим к следующему пакету:
utility-types
Авторы этого пакета не стали замарачиваться с названием... Utility types и есть utility types. Говорит само за себя и совпадает названием оригинального раздела документации typescript.
Вся документация пакета - это один readme файл, что, на мой взгляд, довольно удобно. Содержит не так много утилит, как предыдущий пакет (порядка ~50 утилит на момент написания статьи) - проще запомнить. Они так же разбиты по категориям; для каждого типа в readme есть пример использования, что определенно - плюс. Впрочем, именование утилит довольно так же интуитивное:
DeepReadonly
И сразу понятно, что эта утилита делает все поля типа readonly
(по аналогии со стоковой утилитой Readonly), но с приставкой Deep, что наталкивает на мысли о рекурсивном применении этого действия. Так оно и есть:
import { DeepReadonly } from 'utility-types';
type ReadonlyNestedProps = DeepReadonly<{
first: {
second: {
name: string;
};
};
}>;
/* на выходе получим следующий тип:
{
readonly first: {
readonly second: {
readonly name: string; // поля вложенных объектов так же стали readonly
};
};
}
*/
Справедливости ради хочу отметить, что в предыдущем пакете тоже есть тип с аналогичным функционалом под названием... Readonly - казалось бы, (совпадает с именованием оригинальной утилиты, и, честно говоря, не уверен, что это круто (например, минус автоимпорт), но зато не нужно держать в памяти лишние названия), и принимает два дополнительных необязательных аргумента (по аналогии с рассмотренной нами выше Compulsory опциональный третий аргумент отвечает за рекурсивное поведение. По дефолту - его нет). В общем на вкус и цвет...
SymmetricDifference
Извлекает разность двух объединений:
import { SymmetricDifference } from 'utility-types';
// Expect: "1" | "4"
type ResultSet = SymmetricDifference<'1' | '2' | '3', '2' | '3' | '4'>;
ValuesType
Получает тип объединения всех значений в объекте:
import { ValuesType } from 'utility-types';
type Props = { name: string; age: number; visible: boolean };
// Expect: string | number | boolean
type PropsValues = ValuesType<Props>;
Короче говоря, есть с чем "поиграться". Но не будем надолго останавливаться, поскольку нас ждет следующий пакет:
type-fest
Это, пожалуй, самый звездный пакет из рассматриваемых в этой статье. И если вы "верите в звезды", то вам сюда (на момент написания статьи - их уже почти 13 тысяч). Содержит более сотни типов, разбитых по категориям, а документация - это... исходники этих самых типов с jsdoc... - вот это кстати, как по мне, не очень наглядно (была б оформлена хотя бы в markdown, смотрелась бы лучше; в теории авторы могли бы набросать генератор страниц с того же jsdoc (как это сделали ts‑toolbelt), но... видимо, руки не дошли). Однако, в этой "ненаглядной" документации практически для каждого типа есть вполне наглядный пример использования. А это жирный плюс. Итак, погнали:
OptionalKeysOf
Извлекает все опциональные поля из указанного типа, например:
import type { OptionalKeysOf } from 'type-fest';
type FullUserInfo = {
name: string,
lastname?: string,
phone: string,
email?: string
}
type AdvancedUserInfo = OptionalKeysOf<FullUserInfo> // => "lastname" | "email"
MultidimensionalArray
Создает тип, представляющий многомерный массив заданного типа и размерности:
import type { MultidimensionalArray } from 'type-fest';
type X3array = MultidimensionalArray<number, 3> // => number[][][]
SharedUnionFieldsDeep
Создает тип с общими полями из объединения типов объектов, глубоко обходя вложенные структуры. Пример:
import type {SharedUnionFieldsDeep} from 'type-fest';
type Cat = {
info: {
name: string;
type: 'cat';
catType: string;
};
};
type Dog = {
info: {
name: string;
type: 'dog';
dogType: string;
};
};
type PetInfo = SharedUnionFieldsDeep<Cat | Dog>['info'];
/** на выходе имеем
{
name: string;
type: "cat" | "dog";
}
*/
Не думаю, что эта утилита часто нужна, но в некоторых кейсах может быть очень полезна. А мы переходим к следующему и последнему пакету:
types-spring
Этот в противовес предыдущему звездному пакету является малоизвестным проектом. Но тем не менее он, на мой взгляд, вполне заслуживает внимания.
Вообще изначально являлся даже не библиотекой типов, а скорее патчем для тайпскрипта (о чем на хабре уже была статья, рекомендую почитать). Однако, по мере развития стал обрастать типами, необходимыми для самого патча и не только), настолько, что превратился если и не в полноценную коллекцию универсальных типов, то меньшей мере в прекрасное дополнение к существующим.
Описание типов-утилит лежит в отдельном readme., где почти для любого типа есть примеры использования со ссылками на их исходный код. Имеют интуитивно понятое именование. Типы покрыты тестами. На момент написания этой статьи содержит 23 универсальных типа, которые, по мнению авторов, покрывают самые распространенные кейсы. Рассмотрим:
OptionalExceptOne
Делает новый тип, в котором должно присутствовать как минимум одно (не важно, какое именно(!)) поле исходного типа:
type O = OptionalExceptOne<{ a: 1, b: 1, c: 1 }>
//@ts-expect-error
let o: O = {} // ошибка, поскольку нет полей из исходного типа
let oa: O = { a: 1 } // валидно, т.к. a является полем исх. типа
let ob: O = { b: 1 } // валидно, т.к. b является полем исх. типа
let ocbd: O = { a: 1, b: 1, c: 1 } // тоже валидно
//@ts-expect-error
let od: O = { d: 1 } // ошибка, поскольку нет полей из исходного типа
KeysMatching
Создает объединение из ключей, типы значений которых соответствуют указанному:
type A = { a: number, b: string, c: string };
let strKeys: KeysMatching<A, string> = 'b'
// => переменная strKeys будет иметь тип 'b'|'c'
KeysArray
Создает массив фиксированной длинны из ключей объекта. Довольно тяжелый с точки зрения вычислительной мощности тип (о чем в readme упомянуто), поэтому рекомендуется только для небольших объектов:
type ObjType = {
a: string;
b: string;
c: string;
};
//@ts-expect-error => type "d" is not assignable to type "a" | "b" | "c".
const bar: KeysArray<ObjType> = ["d"];
//@ts-expect-error => type '["a", "b", "c", "d"]' is not assignable to ["a", "b", "c"]
const foo: KeysArray<ObjType> = ["a", "b", "c", "d"];
const objKeys: KeysArray<ObjType> = ["a", "b", "c"];
const objKeys2: KeysArray<ObjType> = ["a", "c", "b"];
Обратите внимание, что полученный в результате тип является не просто константным массивом, а объединением кортежей всех возможных последовательностей, т.е. по сути массивом фиксированной длины, в котором содержится комбинаторное сочетание ключей исходного объекта (простыми словами имеет поведение массива фиксированной длины, последовательность элементов в котором не имеет значения).
ReplaceTypes
Заменяет в типе объекта все указанные типы полей (можно задать несколько с помощью объединения) на заданный тип:
type Profile = {
s: string, b: boolean,
c: { f: string }
}
type Numerized = ReplaceTypes<Profile, string, number>;
/* => в новом типе все поля, которые в исходном имели тип string, стали number:
{
s: number, b: boolean,
c: { f: number }
}
*/
Резюмируем
Разумеется в каждом из представленных пакетов намного больше утилит, чем мы рассмотрели в текущей статье. Но ее целью является лишь их обзор, а не справочное руководство. Резюмируя, я бы подытожил так:
ts-toolbelt - действительно богатая библиотека универсальных типов; несколько страдает документация (отсутствие примеров), но при желании разобраться можно. Использование некоторых утилит может быть не всегда интуитивно понятным и очевидным. Почти все типы сильно взаимосвязаны.
utility-types - менее богатая, покрывает самые распространенные кейсы использования. Удобный readme с примерами, не перегружен информацией. Типы имеют интуитивно понятные имена. Большинство утилит самодостаточны (без ссылок на другие типы - как по мне, это удобно, поскольку позволяет быстро оценить, понять принцип работы либо позаимствовать реализацию с целью кастомизации).
types-fest - библиотека создает впечатление хорошей продуманности типов (вероятно потому и набрала больше звезд). Документация (jsdoc в исходниках) избыточная в хорошем смысле этого слова, но неудобная (ссылка из readme ведет к файлу с типов, в котором могут быть описаны десятки вспомогательных типов на сотни строк кода, и нужный - еще надо искать). Именование, по моему скромному мнению, интуитивно понятное.
types-spring - малоизвестная недооцененная библиотека с хорошей документацией в одном readme, однако содержит ограниченное количество типов. Почти все, как и в случае utility-types, самодостаточны. Покрытие тестами - есть. Интуитивный нэйминг.
Сравнительная таблица некоторых типов
Ниже приведена примерная сводная таблица типов из рассмотренных библиотек
types-fest | types-spring | ts-toolbelt | utility-types |
| - |
| - |
| - |
|
|
| - |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| - |
| - |
| - | - | - |
- | - |
| - |
|
| - | - |
- |
| - |
|
| - |
|
|
|
| - | - |
|
|
| - |
- |
|
|
|
- |
| - | - |
|
|
| - |
Разумеется типов намного больше... Но уже извините, это всего лишь краткий обзор.
Заключение
Возможно, такие наборы типов не представляют ценности для мастодонтов typescript, чьи навыки позволяют реализовывать подобные утилиты за считанные минуты. Это, конечно, если целесообразно писать для каждого проекта с нуля то, что можно заменить одной строкой в devDependencies. Не говоря о том, что использование готовых пакетов унифицирует именование, и команде не нужно теряться в догадках, что за что и для чего. А для новичков и разработчиков, которым в силу их профильности не целесообразно углубляться в тонкости построения сложных типов, на мой взгляд, подобные пакеты могут быть полезны в качестве своеобразного трамплина.
Каково ваше мнение на этот счет, использовали ли вы когда-нибудь библиотеки типов в своих проектах?