Привет, друзья!
Представляю вашему вниманию перевод еще нескольких статей из серии Mastering TypeScript, посвященных углубленному изучению TypeScript.
- TypeScript в деталях. Часть 1
- Заметка о Mapped Types и других полезных возможностях современного TypeScript
- Карманная книга по TypeScript
- Шпаргалка по TypeScript
Пересечения / Intersection types
Тип можно понимать как коллекцию или набор значений. Например, тип number
можно считать множеством (set) всех чисел. 1.0, 68 принадлежат этому множеству, а "bytefer" нет, поскольку типом "bytefer" является string
.
Тоже самое справедливо и в отношении объектных типов, которые можно понимать как коллекции объектов. Например, тип Point
в приведенном ниже примере представляет множество объектов со свойствами x
и y
, значениями которых является числа, а тип Named
представляет множество объектов со свойством name
, значением которого является строка:
interface Point {
x: number;
y: number;
}
interface Named {
name: string;
}
Согласно теории множеств (set theory) множество, содержащее элементы, принадлежащие как множеству A
, так и множеству B
, называется пересечением (intersection) множеств A
и B
:
При пересечении Point
и Named
создается новый тип. Объект нового типа принадлежит как Point
, так и Named
.
TS
предоставляет оператор &
для реализации операции пересечения нескольких типов. Результирующий новый тип называется пересечением (intersection type).
Правила применения оператора &
:
- идентичность (identity): выражение
A & A
эквивалентноA
; - коммутативность (commutativity):
A & B
эквивалентноB & A
(за исключением сигнатур вызова и конструктора, см. ниже); - ассоциативность (associativity):
(A & B) & C
эквивалентноA & (B & C)
; - коллапсирование супертипа (supertype collapsing):
A & B
эквивалентноA
, еслиB
является супертипомA
.
Типы any
и never
являются особенными. Не считая типа never
, пересечением любого типа с any
является any
.
Рассмотрим пересечение типов Point
и Named
:
Новый тип NamedPoint
содержит свойства x
, y
и name
. Но что произойдет при пересечении объектов, содержащих одинаковые свойства со значениями разных типов?
interface X {
c: string;
d: string;
}
interface Y {
c: number;
e: string
}
type XY = X & Y;
type YX = Y & X;
В приведенном примере интерфейсы X
и Y
содержат свойство c
, но значения этого свойства имеют разные типы. Может ли в данном случае значение атрибута c
в типах XY
и YX
быть строкой или числом?
Почему типом значения свойства c
является never
? Дело в том, что значение c
должно быть одновременно и строкой, и числом (string & number
). Но такого типа не существует, поэтому типом значения c
становится never
.
Что произойдет в аналогичном случае с непримитивными значениями? Рассмотрим пример:
При пересечении нескольких типов в случае, когда существует одинаковое свойство и его значением является объект, значения объединяются в соответствии с указанными выше правилами.
Кроме объектных типов, пересечение может применяться в отношении функциональных типов:
При вызове f(1, "bytefer")
возникает ошибка:
No overload matches this call.
Overload 1 of 2, '(a: string, b: string): void', gave the following error.
Argument of type 'number' is not assignable to parameter of type 'string'.
Overload 2 of 2, '(a: number, b: number): void', gave the following error.
Argument of type 'string' is not assignable to parameter of type 'number'.
В данном случае компилятор TS
обращается к перегрузкам функции (function overloading) для выполнения операции пересечения. Для решения проблемы можно определить новый тип функции F3
со следующей сигнатурой:
С помощью пересечения можно реализовать некоторые полезные утилиты типа (utility types). Например, реализуем утилиту PartialByKeys
, которая делает типы значений указанных ключей объекта опциональными:
Аналогичным способом можно реализовать утилиту RequiredByKeys
.
infer
Знаете ли вы, как извлечь тип элементов из массива типа T0
или тип, возвращаемый функцией типа T1
?
type T0 = string[];
type T1 = () => string;
Для этого можно использовать технику поиска совпадений (pattern matching), предоставляемую TS
— сочетание условных типов (conditional types) и ключевого слова infer
.
Условные типы позволяют определять отношения между типами, с их помощью можно определять совпадение типов. infer
используется для определения переменной типа для хранения типа, захваченного (captured) в процессе поиска совпадений.
Рассмотрим, как можно захватить тип элементов массива типа T0
:
type UnpackedArray<T> = T extends (infer U)[] ? U : T;
type U0 = UnpackedArray<T0>; // string
В приведенном примере T extends (infer U)[] ? U : T
— это синтаксис условных типов, а infer U
— это инструкция расширения (extends clause), представляющая новую переменную типа U
для хранения предполагаемого или выводимого (inferred) типа.
Для лучшего понимания рассмотрим поток выполнения (execution flow) утилиты UnpackedArray
:
Обратите внимание: infer
может использоваться только в инструкции расширения условного типа. Переменная типа, объявленная посредством infer
, доступна только в истинной ветке (true branch) условного типа.
type Wrong1<T extends (infer U)[]> = T[0]; // Error
type Wrong2<T> = (infer U)[] extends T ? U : T; // Error
type Wrong3<T> = T extends (infer U)[] ? T : U; // Error
Рассмотрим, как получить тип, возвращаемый функцией T1
:
type UnpackedFn<T> = T extends (...args: any[]) => infer U ? U : T;
type U1 = UnpackedFn<T1>;
Легко, не правда ли?
Обратите внимание: когда речь идет о перезагрузках функции, TS
использует последнюю сигнатуру вызова (call signature) для вывода типа.
В предыдущей статье мы рассмотрели условные цепочки, которые позволяют реализовать более мощную утилиту типа:
type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: any[]) => infer U ? U :
T extends Promise<infer U> ? U :
T;
type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string
В приведенном примере утилита Unpacked
позволяет легко извлекать типы элементов массивов, а также типы, возвращаемые функциями и промисами, благодаря условным типам и условным цепочкам.
Аналогичным способом можно вывести тип ключа объекта. Рассмотрим пример:
type User = {
id: number;
name: string;
}
type PropertyType<T> = T extends { id: infer U, name: infer R } ? [U, R] : T;
type U3 = PropertyType<User>; // [number, string]
В приведенном примере используется две переменных типа: U
и R
, представляющие типы свойств объекта id
и name
, соответственно. При совпадении типов возвращается кортеж (tuple).
Что будет, если определить только переменную типа U
? Давайте проверим:
type PropertyType<T> = T extends { id: infer U, name: infer U } ? U : T;
type U4 = PropertyType<User>; // string | number
Как видите, тип U4
возвращает объединение (union) типов строки и числа. Почему так происходит? Дело в том, что при наличии нескольких кандидатов для одной и той же переменной типа в ковариантной позиции (covariant position), предполагается, что результирующий тип является объединением.
Тем не менее, в аналогичной ситуации, но в контрвариативной позиции (contravariant position), предполагается, что результирующий тип является пересечением (intersection):
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type U5 = Bar<{ a: (x: string) => void, b: (x: number) => void }>; // string & number
В приведенном примере тип U5
возвращает пересечение типов строки и числа, поэтому результирующим типом будет never
.
Наконец, позвольте представить вам новую возможность, появившуюся в TS 4.7
, которая делает процесс вывода типов более согласованным. Но сначала рассмотрим пример:
type FirstIfString<T> = T extends [infer S, ...unknown[]]
? S extends string
? S
: never
: never;
Утилита типа FirstIsString
использует возможности условных типов, условных цепочек и infer
. В первом условии проверяется, что переданный тип T
является непустым кортежем. Там же определяется переменная типа S
для хранения типа первого элемента захваченного в процессе поиска совпадений кортежа.
Во втором условии проверяется, является ли переменная S
подтипом (subtype) строки. Если является, возвращается string
, иначе возвращается never
.
Как видите, утилита FirstIsString
прекрасно справляется со своей задачей. Но можем ли мы ограничиться одним условным типом для достижения того же результата? TS 4.7
позволяет добавлять опциональную инструкцию расширения в предполагаемый тип для определения явных ограничений (explicit constraints) переменной типа:
type FirstIfString<T> =
T extends [infer S extends string, ...unknown[]]
? S
: never;
Напоследок реализуем утилиту для преобразования объединения в пересечение:
type UnionToIntersection<U> = (
U extends any ? (arg: U) => void : never
) extends (arg: infer R) => void
? R
: never;
Шаблонные литеральные типы / Template literal types
При разработке веб-страниц мы часто используем компоненты Tooltip
или Popover
для отображения каких-либо сообщений. Для удовлетворения различных сценариев эти компоненты должны позволять устанавливать позицию сообщения, например, top
, bottom
, left
, right
и т.д.
Соответствующий тип строковых литералов (string literals) можно определить следующим образом:
type Side = 'top' | 'right' | 'bottom' | 'left';
const side: Side = "rigth"; // Error
// Type '"rigth"' is not assignable to type 'Side'.
// Did you mean '"right"'?
Для многих сценариев этого будет достаточно. Но что если мы хотим расширить список доступных позиций? Например, что если сообщение должно отображаться в верхнем правом углу?
Определим тип Placement
посредством расширения типа Side
:
type Placement = Side
| "left-start" | "left-end"
| "right-start" | "right-end"
| "top-start" | "top-end"
| "bottom-start" | "bottom-end"
Глядя на эти строковые литералы, нетрудно заметить дублирующийся код, такой как -start
и -end
. Кроме того, при определении большого количества литералов легко допустить очепятку.
Существует ли лучший способ решения данной задачи? В TS 4.1
были представлены шаблонные литеральные типы (template literal types), позволяющие делать так:
type Alignment = 'start' | 'end';
type Side = 'top' | 'right' | 'bottom' | 'left';
type AlignedPlacement = `${Side}-${Alignment}`;
type Placement = Side | AlignedPlacement;
Как и шаблонные строки (template strings/literals) в JS
, шаблонные типы заключаются в обратные кавычки (``) и могут содержать заменители (placeholders) в форме ${T}
. Передаваемый тип может быть string
, number
, boolean
или bigint
.
Шаблонные типы позволяют объединять (concatenate) строковые литералы и конвертировать литералы непримитивных типов в соответствующие строковые литералы. Вот парочка примеров:
type EventName<T extends string> = `${T}Changed`;
type Concat<S1 extends string, S2 extends string> = `${S1}-${S2}`;
type ToString<T extends string | number | boolean | bigint> = `${T}`;
type T0 = EventName<"foo">; // 'fooChanged'
type T1 = Concat<"Hello", "World">; // 'Hello-World'
type T2 = ToString<"bytefer" | 666 | true | -1234n>;
// "bytefer" | "true" | "666" | "-1234"
Вопрос: каким будет результат, если тип, переданный в утилиту EventName
или Concat
будет объединением? Давайте проверим:
type T3 = EventName<"foo" | "bar" | "baz">;
// "fooChanged" | "barChanged" | "bazChanged"
type T4 = Concat<"top" | "bottom", "left" | "right">;
// "top-left" | "top-right" | "bottom-left" | "bottom-right"
Почему генерируется такой тип? Это объясняется тем, что в случае шаблонных типов объединения в заменителях распределяются по шаблону:
`[${A|B|C}]` => `[${A}]` | `[${B}]` | `[${C}]`
А в случае с несколькими заменителями, как в утилите Concat
, объединения разрешаются в векторное произведение (cross product):
`${A|B}-${C|D}` => `${A}-${C}` | `${A}-${D}` | `${B}-${C}` | `${B}-${D}`
Работая с шаблонными типами, мы также можем применять встроенные утилиты типов для работы со строками, такие как Uppercase
, Lowercase
, Capitalize
и Uncapitalize
:
type GetterName<T extends string> = `get${Capitalize<T>}`;
type Cases<T extends string> = `${Uppercase<T>} ${Lowercase<T>} ${Capitalize<T>} ${Uncapitalize<T>}`;
type T5 = GetterName<'name'>; // "getName"
type T6 = Cases<'ts'>; // "TS ts Ts ts"
Возможности шаблонных типов являются очень мощными. В сочетании с условными типами и ключевым словом infer
можно реализовать, например, такую утилиту вывода типа (type inference):
type InferSide<T> = T extends `${infer R}-${Alignment}` ? R : T;
type T7 = InferSide<"left-start">; // "left"
type T8 = InferSide<"right-end">; // "right"
TS 4.1
также позволяет использовать оговорку as
для переименования ключей при сопоставлении типов:
Тип NewKeyType
должен быть подтипом объединения string | number | symbol
. В процессе переименования ключей посредством шаблонных типов можно реализовать некоторые полезные утилиты.
Например, определим утилиту Getters
для генерации типов геттеров для соответствующего объекта:
В приведенном примере поскольку тип, возвращаемый keyof T
, может содержать тип symbol
, а утилита Capitilize
требует, чтобы обрабатываемый тип был подтипом строки, необходимо выполнить фильтрацию типов с помощью оператора &
.
Попробует реализовать более сложную утилиту. Например, для извлечения типов из объекта с произвольными вложенными свойствами:
type PropType<T, Path extends string> = string extends Path
? unknown
: Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer R}`
? K extends keyof T
? PropType<T[K], R>
: unknown
: unknown;
// см. ниже
declare function getPropValue<T, P extends string>(
obj: T,
path: P
): PropType<T, P>;
declare
Открываем файл определений *.d.ts
и видим там ключевое слово declare
. Знаете ли вы, для чего оно используется?
В TS-проектах
мы часто импортируем сторонние JS-SDK
с помощью тега script
, например, так импортируется Google Maps
:
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyB41DRUbKWJHPxaFjMAwdrzWzbVKartNGg&callback=initMap&v=weekly" defer></script>
Обращаемся к этому API
после инициализации:
Несмотря на то, что все делается в соответствии с официальной документацией, TS
выводит сообщение об ошибке. Это связано с тем, что компилятор TS
не может распознать глобальную переменную google
.
Как решить эту задачу? Использовать ключевое слово declare
для определения глобальной переменной google
:
declare var google: any;
Но почему мы без проблем можем использовать такие глобальные переменные, как JSON
, Math
или Object
? Дело в том, что эти переменные уже объявлены с помощью declare
в файле определений lib.es5.d.ts
:
// typescript/lib/lib.es5.d.ts
declare var JSON: JSON;
declare var Math: Math;
declare var Object: ObjectConstructor;
declare
также может использоваться для определения глобальных функций, классов или перечислений (enums). Такие функции, как eval
, isNaN
, encodeURI
и parseInt
также определяются в lib.es5.d.ts
:
declare function eval(x: string): any;
declare function isNaN(number: number): boolean;
declare function encodeURI(uri: string): string;
declare function parseInt(string: string, radix?: number): number;
Следует отметить, что при определении глобальной функции мы не включаем в определение конкретную реализацию этой функции.
На самом деле в большинстве случаев у нас необходимости определять глобальные переменные, предоставляемые сторонними библиотеками, самостоятельно. Для поиска соответствующих типов можно обратиться к поисковику TypeScript или к проекту DefinitelyTypes.
Устанавливаем типы для Google Maps
в качестве зависимости для разработки:
yarn add -D @types/google.maps
Для npm-пакета
foo
пакет с типами чаще всего будет называться @types/foo
. Например, для библиотеки jquery
пакет с типами называется @types/jquery
.
Посмотрим на использование declare
в реальном проекте. Создаем шаблон Vue-TS-проекта
с помощью Vite:
yarn create vite test-project --template vue-ts
Открываем файл client.d.ts
:
Видим определения модулей css
, jpg
и ttf
. Зачем нам эти модули? Без их определения компилятор TS
не сможет их распознать и будет выводить сообщения об ошибках:
С помощью символа *
можно определить один модуль для конкретного типа файлов вместо того, чтобы указывать каждый ресурс по отдельности:
TS
также позволяет расширять типы, определенные в существующем модуле, с помощью declare
. Например, определим свойство $axios
в каждом экземпляре Vue-компонента
:
import { AxiosInstance } from "axios";
declare module "@vue/runtime-core" {
interface ComponentCustomProperties {
$axios: AxiosInstance;
}
}
Добавляем свойство $axios
в каждый экземпляр компонента с помощью свойства globalProperties
объекта с настройками:
import { createApp } from "vue";
import axios from "axios";
import App from "./App.vue";
const app = createApp(App);
app.config.globalProperties.$axios = axios;
app.mount("#app");
И используем его в компоненте:
import { getCurrentInstance , ComponentInternalInstance} from "vue"
const { proxy } = getCurrentInstance() as ComponentInternalInstance
proxy!.$axios
.get("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then(console.log);
Надеюсь, что вы, как и я, нашли для себя что-то интересное. Благодарю за внимание и happy coding!