TypeScript. Начало работы, часть 7 из 7

15.04.2023

Теги: JavaScriptTypeScriptWeb-разработкаТеорияТипыДанных

Продвинутый TypeScript

1. Оператор keyof

В JavaScript ключи объекта извлекаются с помощью метода Object.keys

const user = {
    id: 12345,
    name: 'Сергей',
};
const keys = Object.keys(user); // ['id', 'name']

В TypeScript это делается с помощью оператора keyof

interface User {
    id: number;
    name: string;
}
type UserKeys = keyof User; // 'id' | 'name'

После получения ключа объектного типа, мы можем получить доступ к типу значения, соответствующему данному ключу, с помощью синтаксиса, аналогичного синтаксису доступа к свойству объекта.

type U1 = User['id']; // number
type U2 = User['id' | 'name']; // string | number
type U3 = User[keyof User]; // string | number

В приведенном примере используется тип индексированного доступа (indexed access type) для получения типа определенного свойства типа User. Как keyof используется на практике? Давайте рассмотрим пример.

function getProperty(obj: object, key: string) {
    return obj[key];
}

const user = {
    id: 12345,
    name: 'Сергей',
};

const userName = getProperty(user, 'name');

TypeScript выдает ошибку на единственной строке тела функции.

Элемент неявно содержит тип «any», так как выражение типа «string» не может быть использовано для индексации типа «{}».

Element implicitly has an «any» type because expression of type «string» can't be used to index type «{}».

Эта ошибка означает, что TypeScript не знает, может ли параметр key типа string быть индексом параметра obj типа object. Чтобы избавиться от ошибки, нам надо сообщить TypeScript тип ключей, которые может использовать параметр obj.

interface BaseObject {
    [key: string]: any,
}

function getProperty(obj: BaseObject, key: string) {
    return obj[key];
}

const user = {
    id: 12345,
    name: 'Сергей',
};

const userName = getProperty(user, 'name');

От ошибки мы избавились, но сейчас мы можем вызвать функцию getProperty, передавая в качестве второго аргумента несуществующее свойство age. И об этой ошибке TypeScript нам не сообщит, пока мы не столкнемся с последствиями уже на этапе выполнения.

const userName = getProperty(user, 'age');

Мы можем исправить это, если используем обобщённый тип (generic type)

function getProperty<T extends object, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}

const user = {
    id: 12345,
    name: 'Сергей',
};

const userName = getProperty(user, 'age'); // ошибка

Мы определяем два параметра типа — T и K. Ключевое слово extends применяется, во-первых, для ограничения (constraint) типа, передаваемого T, подтипом объекта, во-вторых, для ограничения типа, передаваемого K, подтипом объединения ключей объекта.

Оператор keyof может применяться не только к объектам, но также к примитивам, типу any, классам и перечислениям.

class Person {
    id = 12345;
    name = 'Сергей';
}
type P = keyof Person; // 'id' | 'name'

enum HttpMethod {
    Get,
    Post,
}
type M = keyof typeof HttpMethod; // 'Get' | 'Post'

2. Оператор typeof

Рассмотрим несколько полезных примеров использования оператора typeof.

2.1. Получение типа объекта

Для небольших объектов ручное определение типа не составляет труда, но для больших и сложных объектов с несколькими уровнями вложенности это может быть утомительным.

const country = {
    name: 'Германия',
    language: 'немецкий',
    capital: {
        name: 'Берлин',
        population: 3375000,
        year: 1237,
    },
};
interface Country {
    name: string;
    language: string;
    capital: {
        name: string;
        population: number;
        year: number;
    };
}

Вместо ручного определения типа объекта можно прибегнуть к помощи оператора typeof.

const country = {
    name: 'Германия',
    language: 'немецкий',
    capital: {
        name: 'Берлин',
        population: 3375000,
        year: 1237,
    },
};

type Country = typeof country;
type Capital = Country['capital'];

2.2. Получение типа, представляющего все ключи перечисления в виде строк

В TypeScript перечисление (enum) — это специальный тип, компилирующийся в обычный javascript-объект.

enum HttpMethod {
    GET,
    POST,
    PUT,
    DELETE,
}
"use strict";
var HttpMethod;
(function (HttpMethod) {
    HttpMethod[HttpMethod["GET"] = 0] = "GET";
    HttpMethod[HttpMethod["POST"] = 1] = "POST";
    HttpMethod[HttpMethod["PUT"] = 2] = "PUT";
    HttpMethod[HttpMethod["DELETE"] = 3] = "DELETE";
})(HttpMethod || (HttpMethod = {}));

Поэтому к перечислениям также можно применять оператор typeof. Однако, в случае с перечислениями, typeof комбинируется с оператором keyof.

type Method = keyof typeof HttpMethod; // 'GET' | 'POST' | 'PUT' | 'DELETE'

2.3. Получение типа функции

Функция в JavaScript — это тоже объект, так что можно использовать typeof для получения типа функции. После этого можно воспользоваться утилитами типов ReturnType и Parameters для получения типа возвращаемого функцией значение и типа ее параметров.

function sum(a: number, b: number) {
    return a + b;
}

type SumType = typeof sum; // (a: number, b: number) => number
type SumReturnType = ReturnType<SumType>; // number
type SumParamsType = Parameters<SumType>; // [a: number, b: number]

2.4. Получение сигнатуры конструктора класса

В приведенном ниже примере createPoint — это фабричная функция, создающая экземпляры класса Point. С помощью typeof можно получить сигнатуру конструктора класса Point для реализации проверки соответствующего типа.

class Point {
    constructor(public x: number, public y: number) {}
}

interface PointConstructorType {
    new (x: number, y: number): Point;
}

function createPoint(
    PointConstructor: PointConstructorType,
    x: number,
    y: number
) {
    return new PointConstructor(x, y);
}
class Point {
    constructor(public x: number, public y: number) {}
}

function createPoint(
    // typeof позволяет получить сигнатуру конструктора
    PointConstructor: typeof Point,
    x: number,
    y: number
) {
    return new PointConstructor(x, y);
}

3. Связанные типы

Допустим, у нас есть тип User, в котором все поля являются обязательными.

type User = {
    name: string;
    password: string;
    address: string;
    phone: string;
}

Потом потребовалось создать тип OptionalUser, в котором все поля являются необязательными.

type OptionalUser = {
    name?: string;
    password?: string;
    address?: string;
    phone?: string;
}

Потом еще потребовалось создать тип ReadonlyUser, в котором все поля только для чтения.

type ReadonlyUser = {
    readonly name: string;
    readonly password: string;
    readonly address: string;
    readonly phone: string;
}

В итоге получилось много дублирующегося кода, который хотелось бы сократить для удобства работы.

И здесь нам на помощь приходят связанные типы, позволяющими связывать тип исходного объекта с типом нового объекта.

Синтаксис связанных типов простой. P in K можно сравнить с инструкцией for..in, она используется для перебора всех ключей типа K. Тип T — это любой тип, валидный с точки зрения TypeScript.

В процессе связывания типов могут использоваться дополнительные модификаторы «опциональный» и «только для чтения». Соответствующие модификаторы добавляются и удаляются с помощью символов плюс и минус. По умолчанию модификатор добавляется, то есть используется плюс.

{ [ P in K ] : T }
{ [ P in K ] ?: T }
{ [ P in K ] -?: T }
{ readonly [ P in K ] : T }
{ readonly [ P in K ] ?: T }
{ -readonly [ P in K ] ?: T }

Теперь можем упростить наш код — для этого добавим типы OptionalProperties и ReadonlyProperties.

type User = {
    name: string;
    password: string;
    address: string;
    phone: string;
}

type OptionalProperties<T> = {
    [P in keyof T]?: T[P];
};

type ReadonlyProperties<T> = {
    readonly [P in keyof T]: T[P];
};

type OptionalUser = OptionalProperties<User>;
type ReadonlyUser = ReadonlyProperties<User>;

4. Условные типы

Условные типы (conditional types) позволяют вводить логику if/else в определения типов и делать их более динамичными. Для этого используется тернарный синтаксис для определения условных операторов. Они начинаются с указания самого условия с применением нотации SomeType extends OtherType, за которой следуют ветки этого условия true и false.

Вот очень простой условный тип, который будет создавать литеральные типы true и false в зависимости от получаемого параметра типа.

type IsOne<Type> = Type extends 1 ? true : false;

type TypeOne = IsOne<1>; // => true
type TypeTwo = IsOne<2>; // => false

Можно сказать, что условные типы (conditional types) дополняют возможности обобщенных типов (generic type). Рассмотрим еще один пример — реализация интерфейса Item может работать со строкой и числом.

interface StringContainer {
    value: string;
    format(): string;
    split(): string[];
}

interface NumberContainer {
    value: number;
    square(): number;
    round(): number;
}

interface Item<Type> {
    id: number;
    container: Type extends string ? StringContainer : NumberContainer;
}

Можно также вложить несколько тернарных операторов для описания более сложных условий.

type ArrayItemType<Type> = Type extends number[]
    ? number
    : Type extends string[]
    ? string
    : Type extends boolean[]
    ? boolean
    : unknown;

type ArrayItemNumber = ArrayItemType<number[]>; // => number
type ArrayIteString = ArrayItemType<string[]>; // => string
type ArrayItemBoolean = ArrayItemType<boolean[]>; // => boolean

Запись «Type extends string» означает, что Тype — это некий тип, являющийся подмножеством типа string. Но так это работает только с примитивными типами — если мы используем вместо string тип объекта с определенным набором свойств, то это наоборот означает, что Тype является надмножеством этого типа.

type TypeOne = { a: string; b: number } extends { a: string } ? true : false; // true
type TypeTwo = { a: string } extends { a: string; b: number } ? true : false; // false
let one: { a: string } = {
    a: 'some string',
};
let two: { a: string; b: number } = {
    a: 'other string',
    b: 12345,
};
one = two; // можно
two = one; // нельзя

5. Ключевое слово infer

Ключевое слово infer расширяет возможности условных типов. Это что-то вроде ключевого слова var, но для типов — позволяет получить и сохранить тип в переменной, чтобы потом вернуть. Для примера получим тип возвращаемого функцией значения.

type MyReturnType<T> = T extends (...args: any) => infer R ? R : never;

type ReturnTypeString = MyReturnType<() => string>; // string
type ReturnTypeNumber = MyReturnType<() => number>; // number
type ReturnTypeStringNumber = MyReturnType<() => string | number>; // string | number
type ReturnTypeArrayBoolean = MyReturnType<(a: boolean, b: boolean) => boolean[]>; // boolean[]

Когда T принимает значение типа функции — возвращаемый функцией тип значения запоминается в R и возвращается. В следующем примере получим тип параметров функции.

type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

type ParamsTypeString = MyParameters<(a: string) => void>; // [a: string]
type ParamsTypeNumber = MyParameters<(a: number) => void>; // [a: number]
type ParamsTypeStringNumber = MyParameters<(a: number, b: string) => void>; // [a: number, b: string]
type ParamsTypeArrayBoolean = MyParameters<(a: boolean[]) => void>; // [a: boolean[]]

6. Ключевое слово declare

Для установки связи с внешними файлами js-скриптов в TypeScript служат декларативные или заголовочные файлы. Это файлы с расширением .d.ts, они описывают синтаксис и структуру функций и свойств, которые могут использоваться в приложении, не предоставляя при этом конкретной реализации.

Рассмотрим, как мы можем использовать заголовочные файлы. Иногда в javascript используются глобальные переменные, которые должны быть видны везде. Например, пусть на веб-странице (или в подключаемом файле javascript) в коде определена переменная.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Заголовочные файлы в TypeScript</title>
    <script>
        let helloMessage = 'Hello TypeScript!';
    </script>
    <script src="app.js"></script>
</head>
<body>
    <h1>Заголовочные файлы в TypeScript</h1>
</body>
</html>

И мы хотим использовать эту переменную в typescript-коде в файле app.ts.

console.log(helloMessage);

При запуске приложения компилятор не сможет скомпилировать приложение, так как для TypeSscript глобальная переменная пока не существует. В этом случае нам надо подключить определение глобальной переменной с помощью декларативного файла. Для этого добавим в проект новый файл global.d.ts, который будет иметь следующее содержимое.

declare let helloMessage: string;

Подобным образом мы можем подключать другие компоненты кода JavaScript — функции, объекты, классы. Допустим, на веб-странице в javascript-коде объявлены переменная, функция, объект и класс.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Заголовочные файлы в TypeScript</title>
    <script>
        let helloMessage = 'Hello TypeScript!';
        let printMessage = (message) => console.log(message);
        let user = {
            name: 'Сергей',
            age: 37,
            print() {
                console.log(`Имя ${this.name}, возраст ${this.age}`);
            }
        }
        class Person {
            constructor(name, age) {
                this.name = name;
                this.age = age;
            }
            display() {
                console.log(`Имя ${this.name}, возраст ${this.age}`);
            }
        }
    </script>
    <script src="app.js"></script>
</head>
<body>
    <h1>Заголовочные файлы в TypeScript</h1>
</body>
</html>

Для использования этой переменной, функции, объекта и класса в typescript-коде — файл global.d.ts должен иметь следующий вид.

declare let helloMessage: string;
declare let printMessage: (message: string) => void;
declare const user: { name: string; age: number; print: () => void };
declare class Person {
    name: string;
    age: number;
    constructor(name: string, age: number);
    display(): void;
}

Заголовочные файлы для библиотек

При разработке приложения, как правило, приходится взаимодействовать с крупными библиотеками — например, jQuery. Чтобы использовать функционал этих библиотек в коде на TypeScript — для них надо создать свои файлы определений. И это может быть довольно утомительно в виду сложности библиотек и больших объемов кода. В сообществе TypeScript возникла идея создать общий репозиторий для подобных файлов — это DefinitelyTyped.

> npm install --save-dev @types/jquery

В проекте будет создана директория node_modules/@types, в котором поддиректория jquery будет хранить заголовочные файлы для библиотеки jQuery. Значение по умолчанию опции typeRoots файла кофигурации tsconfig.json предписывает искать файлы определений в директориях ./node_modules/@types/, ../node_modules/@types/, ../../node_modules/@types/ — и так далее. Если установить свое значение typeRoots — будут включены только указанные пакеты, значение по умолчанию будет утеряно.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Заголовочные файлы в TypeScript</title>
    <script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
    <script src="app.js"></script>
</head>
<body>
    <div id="content"></div>
</body>
</html>
// файл app.ts
jQuery(function ($) {
    $('#content').html('<h1>Заголовочные файлы в TypeScript</h1>');
});

7. Утилиты типа

Утилиты типа (utility types) позволяют легко конвертировать, извлекать, исключать типы, получать параметры типов и типы значений, возвращаемых функциями.

7.1. Partial<Type>

Данная утилита делает все свойства Type опциональными (необязательными).

/**
 * Make all properties in T optional.
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};
type User = {
    name: string;
    email: string;
    phone: string;
};

type PartialUser = { // плохо
    name?: string;
    email?: string;
    phone?: string;
};

type PartialUser = Partial<User>; // хорошо

7.2. Required<Type>

Данная утилита делает все свойства Type обязательными (противоположность Partial).

/**
 * Make all properties in T required.
 */
type Required<Type> = {
    [P in keyof T]-?: T[P];
};
type User = {
    name?: string;
    email?: string;
    phone?: string;
};

type RequiredUser = { // плохо
    name: string;
    email: string;
    phone: string;
};

type RequiredUser = Required<User>; // хорошо

7.3. Readonly<Type>

Данная утилита делает все свойства Type доступными только для чтения (readonly). Такие свойства являются иммутабельными (их значения нельзя изменять).

/**
 * Make all properties in T readonly.
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};
type User = {
    name: string;
    email: string;
    phone: string;
};

type ReadonlyUser = { // плохо
    readonly name: string;
    readonly email: string;
    readonly phone: string;
};

type ReadonlyUser = Readonly<User>; // хорошо

7.4. Record<Type>

Данная утилита создает новый объектный тип (object type), ключами которого являются Keys, а значениями свойств — Type.

/**
 * Construct a type with a set of properties K of type T.
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};
type Keys = 'a' | 'b' | 'c';
type RecordType = Record<Keys, number>;

// record может быть { a: number, b: number, c: number }
let record: RecordType;
interface CatInfo {
    name: string;
    age: number;
    breed: string;
}

type CatIds =
    | 'e690a91e-9269-4c1b-a112-342bef7f4be9'
    | '78f2ffdb-8423-4f7c-9094-ef8838f43c4f'
    | 'ffd041a6-42e9-4e71-92a3-7a7a5d495c43';

const cats: Record<CatIds, CatInfo> = {
    'e690a91e-9269-4c1b-a112-342bef7f4be9': {
        name: 'Васька',
        age: 10,
        breed: 'Персидская',
    },
    '78f2ffdb-8423-4f7c-9094-ef8838f43c4f': {
        name: 'Мурка',
        age: 5,
        breed: 'Сиамская',
    },
    'ffd041a6-42e9-4e71-92a3-7a7a5d495c43': {
        name: 'Барсик',
        age: 12,
        breed: 'Ангорская',
    },
};

7.5. Exclude<UnionType, ExcludedMembers>

Данная утилита создает новый тип посредством исключения из UnionType всех членов объединения, которые могут быть присвоены (assignable) ExcludedMembers.

/**
 * Exclude from T those types that are assignable to U.
 */
type Exclude<T, U> = T extends U ? never : T;

Простыми словами, из типа T будут исключены признаки (ключи), присущие также и типу U. Очень условно можно сказать, что из T вычитается U.

// из типа T=number|string исключается number, потому что number есть в U=number|boolean
// из типа T=number|string не исключается string, потому что string нет в U=number|boolean
type TypeOne = Exclude<number | string, number | boolean>; // string
// из типа T=number|string не исключается number, потому что number нет в U=boolean|object
// из типа T=number|string не исключается string, потому что string нет в U=boolean|object
type TypeTwo = Exclude<number | string, boolean | object>; // number|string

В случае, если оба аргумента типа принадлежат к одному и тому же типу данных, Exclude<T, U> будет представлен типом never.

7.6. Extract<Type, Union>

Данная утилита создает новый тип посредством извлечения из Type всех членов объединения, которые могут быть присвоены Union.

/**
 * Extract from T those types that are assignable to U.
 */
type Extract<T, U> = T extends U ? T : never;

Простыми словами, из типа T будут извлечены признаки (ключи), присущие также и типу U. Новый тип будет содержать признаки (ключи), присущие обоим типам T и U. Очень условно можно сказать, что это пересечение T и U.

// из типа T=number|string извлекается number, потому number есть в U=number|string
// из типа T=number|string извлекается string, потому string есть в U=number|string
type TypeOne = Extract<number | string, number | string>; // number|string

// из типа T=number|string извлекается number, потому number есть в U=number|boolean
// из типа T=number|string не извлекается string, потому string нет в U=number|boolean
type TypeTwo =  Extract<number | string, number | boolean>; // number

7.7. Pick<Type, Keys>

Данная утилита создает новый тип посредством извлечения из Type набора (множества) свойств Keys (Keys — строковый литерал или их объединение).

/**
 * From T, pick a set of properties whose keys are in the union K.
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
type User = {
    name: string;
    email: string;
    phone: string;
    age: number;
};

type PartialUser = { // плохо
    name: string;
    phone: string;
};

type PartialUser = Pick<User, 'name' | 'phone'>; // хорошо

7.8. Omit<Type, Keys>

Данная утилита создает новый тип посредством исключения из Type набора (множества) свойств Keys (Keys — строковый литерал или их объединение). Утилита Omit является противоположностью утилиты Pick.

/**
 * Construct a type with the properties of T except for those in type K.
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type User = {
    name: string;
    email: string;
    phone: string;
    age: number;
};

type PartialUser = { // плохо
    name: string;
    phone: string;
};

type PartialUser = Omit<User, 'email' | 'age'>; // хорошо

7.9. NonNullable<Type>

Данная утилита создает новый тип посредством исключения из Type типов null и undefined.

/**
 * Exclude null and undefined from T.
 */
type NonNullable<T> = T extends null | undefined ? never : T;
type Value = string | number | null | undefined;

type NonNullableValue = string | number; // плохо

type NonNullableValue = NonNullable<Value>; // хорошо

7.10. Parameters<Type>

Данная утилита создает кортеж (tuple) из типов параметров функции Type.

/**
 * Obtain the parameters of a function type in a tuple.
 */
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
function getUserInfo(id: number, name: string) {
    return `Идентификатор ${id}, имя ${name}`;
}

type FuncParamsType = Parameters<typeof getUserInfo>; // [id: number, name: string]

7.11. ReturnType<Type>

Данная утилита извлекает тип значения, возвращаемого функцией Type.

/**
 * Obtain the return type of a function type.
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
function getUserInfo(id: number, name: string) {
    return `Идентификатор ${id}, имя ${name}`;
}

type FuncReturnType = ReturnType<typeof getUserInfo>; // string

7.12. Uppercase<StringType>

Данная утилита конвертирует строковый литеральный тип в верхний регистр.

type Method = 'get' | 'post' | 'put' | 'delete';

type UppercaseMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; // плохо

type UppercaseMethod = Uppercase<Method>; // хорошо

7.13. Lowercase<StringType>

Данная утилита конвертирует строковый литеральный тип в верхний регистр.

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

type LowercaseMethod = 'get' | 'post' | 'put' | 'delete'; // плохо

type LowercaseMethod = Lowercase<Method>; // хорошо

7.14. Capitalize<StringType>

Данная утилита конвертирует первый символ строкового литерального типа в верхний регистр.

type Method = 'get' | 'post' | 'put' | 'delete';

type CapitalizeMethod = 'Get' | 'Post' | 'Put' | 'Delete'; // плохо

type CapitalizeMethod = Capitalize<Method>; // хорошо

7.15. Uncapitalize<StringType>

Данная утилита конвертирует первый символ строкового литерального типа в верхний регистр.

type Method = 'Get' | 'Post' | 'Put' | 'Delete';

type UncapitalizeMethod = 'get' | 'post' | 'put' | 'delete'; // плохо

type UncapitalizeMethod = Uncapitalize<Method>; // хорошо

Поиск: JavaScript • TypeScript • Web-разработка • Теория • Типы данных • keyof • typeof

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.