Привет, друзья!


Представляю вашему вниманию перевод еще нескольких статей из серии Mastering 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!




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