TypeScript — это язык, расширяющий JavaScript, добавляя в последний типизацию. Правда, так как TypeScript не имеет runtime-а (почти), он транслируется в JavaScript, в процессе чего, вся типизация теряется. Такую типизацию можно назвать лишь инструментом статического анализа кода. Тем не менее — это очень мощный инструмент. К тому же, помимо проверки кода, типизация также и документирует его.
В данной статье я расскажу лишь про типы, объявленные через ключевое слово type
, не касаясь интерфейсов и классов. Однако, эта тема шире, чем может показаться, и я надеюсь, что читатель узнает что-то новое для себя. Ведь с помощью type
можно писать маленькие программки (далее, утилиты), которые выполняются в процессе статического анализа кода, расширяя его возможности.
Множества.
Самый простой способ использования ключевого слова type
- объявление некоторого множества:
type X = "a" | "b" | number | null;
const a: X = 3;
const b: X = "c"; // Error! Type '"c"' is not assignable to type 'X'.
Теперь рассмотрим пример использования утилиты Extract
из стандартной библиотеки TypeScript (Built-in utility types). Имеем множества A
и B
. С помощью утилиты Extract
получаем такое подмножество C
, в которое входят те элементы из множества A
, которые есть в множестве B
:
type A = "a" | "b" | "c";
type B = "a" | "d";
type C = Extract<A, B>; // type C = "a";
Теперь рассмотрим реализацию утилиты Extract
. В утилитах для ветвления используется тернарный оператор ?:
:
type Extract<T, U> = T extends U ? T : never;
Необходимо усвоить, что ключевое слово extends
в данном случае представляет собой не только условие, но и неявный цикл, так как условие применяется для каждого элемента множества T
, проверяя, принадлежит ли этот элемент множеству U
. Также, обратите внимание на использование never
. Тернарной оператор подразумевает строгое ветвление на if
и else
. Если какой-то из веток нет в логике, явно указываем это типом never
.
Часто используемый способ объявления множества из ключей объекта - ключевое слово keyof
:
interface A {
a1: string;
a2: string;
}
type X = keyof A; // type X = "a1" | "a2";
const b = { b1: "", b2: "" };
type Y = keyof typeof b; // type Y = "b1" | "b2";
Утилиты для объявления объектов.
Объект через ключевое слово type
можно объявить следующим образом:
type SomeObject<Keys extends string | number | symbol, Values> = {
[Key in Keys]: Values;
};
В данном случае, Keys
- множество ключей объекта, а Values
- множество типов значений объекта. Key
в бинарном операторе in
- объявление типа, в который в цикле будут подставляться элементы множества Keys
.
Для тренировки, попробуйте проанализировать следующий код:
type XYZ = "x" | "y" | "z";
type PickXY<T extends Record<XYZ, any>> = {
[Key in Exclude<XYZ, "z">]: T[Key];
};
// использование:
interface Vector3D {
x: number;
y: number;
z: number;
extra: any;
}
type Vector = PickXY<Vector3D>; // type Vector = { x: number; y: number; }
Далее, рассмотрим реализацию утилиты Partial
. Для примера:
// предположим, есть тип
interface X {
a: string;
b: number;
}
// необходимо объявить тип, для которого каждое
// из свойств исходного типа не обязательно, т.е.
interface X1 {
a?: string;
b?: number;
}
Как видно, для решения задачи, используется суффикс ?
. Собственно, для реализации Partial
тоже задействован ?
следующим образом:
type Partial<T> = {
[key in keyof T]?: T[Key];
// ^
// или тоже самое
// [key in keyof T]+?: T[Key];
// ^^
};
Убрать необязательность можно с помощью суффикс -?
:
type Required<T> = {
[key in keyof T]-?: T[Key];
// ^^
};
Таким же образом можно убрать/добавить модификатор readonly
:
type Readonly<T> = {
readonly [Key in keyof T]: Type[Key];
};
type Writable<T> = {
-readonly [Key in keyof T]: Type[Key];
};
А для закрепления материала, рассмотрим утилиту, которая некоторые заданные свойства исходного типа делает readonly
, а остальные - необязательными. Обратите внимание, что конечный тип объединяет в себе два типа с помощью оператора &
:
type Example<T, ReadonlyKeys> = {
readonly [Key in Extract<keyof T, ReadonlyKeys>]: T[Key];
} & {
[Key in Exclude<keyof T, ReadonlyKeys>]+?: T[Key];
};
interface Source {
a: string;
b: number;
c: boolean;
d: boolean;
}
type Target = Example<Source, "a" | "c" | "x">;
/*
type Target = {
readonly a: string;
b?: number;
readonly c: boolean;
d?: boolean;
}
*/
Работа с функциями
Первый вид работы с функциями - получение типов аргументов функции или типа, возвращаемого функцией. К таким, например, относятся утилиты Parameters<Type>
, ReturnType<Type>
или InstanceType<Type>
.
Предположим, нужно написать утилиту, которая будет получать тип второго аргумента. Назовем ее SecondArg<Type>
. Конечно, такую утилиту можно выразить через встроенную утилиту Parameters<Type>
:
type SecondArg<T extends (...args: any) => any> = Parameters<T>[1];
// использование:
function x(n: number, s: string) {}
type A = SecondArg<typeof x>; // type A = string;
Но для понимания работы Parameters<Type>
, напишем реализацию "с нуля". В основе будет лежать тернарный оператор ?:
и оператор extends
:
type SecondArg<T> =
T extends (_: any, arg: infer U, ...rest: any[]) => any
? U
: never;
Оператор extends
проверяет, является ли тип T
функцией, в которой есть некоторый первый аргумент любого типа и второй аргумент типа U
. Также у функции могут быть другие аргументы, но это не важно. Перед типом U
стоит ключевое слово infer
, означающее, что обобщенный (generic) тип U
объявляется через выведение (inference) типа. Проверяем:
function x(...values: string[]) {}
function y() {}
type A = SecondArg<typeof x>; // type A = string;
type B = SecondArg<typeof y>; // type B = unknown;
const b1: B = "string";
const b2: B = {}; // любое значение не вызовет ошибку
Обратите внимание, что для типа B
был выведен тип unknown
, т.е. по сути any
. Учтем это и поправим реализацию:
type SecondArg<T> =
T extends (_: any, arg: infer U, ...rest: any[]) => any
? U extends unknown
? never
: U
: never;
// теперь
function y() {}
type B = SecondArg<typeof y>; // type B = never;
const b: B = 1; // Type 'number' is not assignable to type 'never'.
Теперь рассмотрим другой способ работы с типами в функциях. Разберем такую функцию, возвращаемый тип которой выводится через аргумент:
function toArray<T>(value: T): T[] {
return [value];
}
const x = toArray(1); // const x: number[]
const y = toArray("a"); // const y: string[]
И запишем такое повевение в виде типа:
type ToArray = <T>(value: T) => T[];
Здесь обобщенный (generic) тип T
объявляется cправa от =
, что означает, что этот тип будет выводится. Рассмотрим пример, где это может использоваться. Допустим, у нас есть функции для получения каких-то моделей с сервера. Например:
function getUsers(): Promise<User[]> {
// ...
}
Также есть функция, которая запрашивает эти данные в какой-то момент времени и передает модели на обработку. Запишем тип:
type DataProcess = <T>(options: {
provider(): Promise<T>;
callback(value: T): void;
}) => void;
Использоваться функция типа DataProcess
будет следующим образом:
dataProcess({
provider: getUsers,
// ide поймет, что users: User[]
callback: (users) => {
// name - свойство типа User
users[0].name;
},
});
Заключение
Конечно, в большинстве случаев можно (и нужно) обойтись объявлением типов через ключевое слово interface
. Но иногда бывают хитрые ситуации, и типизация typescript
способна предоставить мощные инструменты для решения нестандартных задач. Также не забывайте про стандартную библиотеку утилит при написании функций и методов. И да поможет вам статический анализ кода! ;)