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 способна предоставить мощные инструменты для решения нестандартных задач. Также не забывайте про стандартную библиотеку утилит при написании функций и методов. И да поможет вам статический анализ кода! ;)

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


  1. Drag13
    07.02.2022 10:36

    Большое спасибо за - , долго искал. Не подскажите ссылку на доку?