habrahabr

TypeScript в деталях. Часть 3

  • пятница, 30 сентября 2022 г. в 00:40:05
https://habr.com/ru/company/timeweb/blog/690726/
  • Блог компании Timeweb Cloud
  • Разработка веб-сайтов
  • JavaScript
  • TypeScript




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


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



15 встроенных утилит типа


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


1. Partial<Type>


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





/**
 * Make all properties in T optional.
 * typescript/lib/lib.es5.d.ts
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};




2. Required<Type>


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





/**
 * Make all properties in T required.
 * typescript/lib/lib.es5.d.ts
 */
type Required<T> = {
    [P in keyof T]-?: T[P];
};




3. Readonly<Type>


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





/**
 * Make all properties in T readonly.
 * typescript/lib/lib.es5.d.ts
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};




4. Record<Keys, Type>


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





/**
 * Construct a type with a set of properties K of type T.
 * typescript/lib/lib.es5.d.ts
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

5. Exclude<UnionType, ExcludedMembers>


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





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




6. Extract<Type, Union>


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





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




7. Pick<Type, Keys>


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





/**
 * From T, pick a set of properties whose keys are in the union K.
 * typescript/lib/lib.es5.d.ts
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};




8. Omit<Type, Keys>


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





/**
 * Construct a type with the properties of T except for those
 * in type K.
 * typescript/lib/lib.es5.d.ts
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;




9. NonNullable<Type>


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





/**
 * Exclude null and undefined from T.
 * typescript/lib/lib.es5.d.ts
 */
type NonNullable<T> = T extends null | undefined ? never : T;

10. Parameters<Type>


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





/**
 * Obtain the parameters of a function type in a tuple.
 * typescript/lib/lib.es5.d.ts
 */
type Parameters<T extends (...args: any) => any> = T extends
  (...args: infer P) => any ? P : never;

11. ReturnType<Type>


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





/**
 * Obtain the return type of a function type.
 * typescript/lib/lib.es5.d.ts
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

12. Uppercase<StringType>


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





13. Lowercase<StringType>


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





14. Capitilize<StringType>


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





15. Uncapitilize<StringType>


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





Кроме описанных выше, существует еще несколько встроенных утилит типа:


  • ConstructorParameters<Type>: создает кортеж или массив из конструктора функции (речь во всех случаях идет о типах). Результатом является кортеж всех параметров типа (или тип never, если Type не является функцией);
  • InstanceType<Type>: создает тип, состоящий из типа экземпляра конструктора функции типа Type:
  • ThisParameterType<Type>: извлекает тип из параметра this функции. Если функция не имеет такого параметра, возвращается unknown.

10 особенностей классов


В объектно-ориентированных языках программирования класс — это шаблон (blueprint — проект, схема), описывающий свойства и методы, которые являются общими для всех объектов, создаваемых с помощью класса.


1. Свойства и методы


1.1. Свойства экземпляров и статические свойства


В TS, как и в JS, класс определяется с помощью ключевого слова class:


class User {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

В приведенном примере определяется класс User с одним свойством экземпляров name. В действительности, класс — это синтаксический сахар для функции-конструктора. Если установить результат компиляции в ES5, то будет сгенерирован следующий код:


"use strict";
var User = /** @class */ (function () {
    function User(name) {
        this.name = name;
    }
    return User;
}());

Кроме свойств экземпляров, в классе могут определяться статические свойства. Такие свойства определяются с помощью ключевого слова static:


class User {
    static cid: string = "eft";
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

В чем разница между свойствами экземпляров и статическими свойствами? Посмотрим на компилируемый код:


"use strict";
var User = /** @class */ (function () {
    function User(name) {
        this.name = name;
    }

    User.cid = "eft";

    return User;
}());

Как видим, свойства экземпляров определяются в экземпляре класса, а статические свойства — в его конструкторе.


1.2. Методы экземпляров и статические методы


Кроме свойств, в классе могут определяться методы экземпляров и статические методы:


class User {
  static cid: string = "eft";
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  static printCid() {
    console.log(User.cid);
  }

  send(msg: string) {
    console.log(`${this.name} send a message: ${msg}`);
  }
}

В чем разница между методами экземпляров и статическими методами? Посмотрим на компилируемый код:


"use strict";
var User = /** @class */ (function () {
    function User(name) {
        this.name = name;
    }

    User.printCid = function () {
        console.log(User.cid);
    };

    User.prototype.send = function (msg) {
        console.log("".concat(this.name, " send a message: ").concat(msg));
    };

    User.cid = "eft";

    return User;
}());

Как видим, методы экземпляров добавляются в прототип конструктора, а статические методы в сам конструктор.


2. Аксессоры


В классе могут определяться так называемые аксессоры (accessors). Аксессоры, которые делятся на геттеры (getters) и сеттеры (setters) могут использоваться, например, для инкапсуляции данных или их верификации:


class User {
  private _age: number = 0;

  get age(): number {
    return this._age;
  }

  set age(value: number) {
    if (value > 0 && value <= 120) {
      this._age = value;
    } else {
      throw new Error("The set age value is invalid!");
    }
  }
}

3. Наследование


Наследование (inheritance) — это иерархическая модель для связывания классов между собой. Наследование — это возможность класса наследовать функционал другого класса и расширять его новым функционалом. Наследование — это наиболее распространенный вид отношений между классами, между классами и интерфейсами, а также между интерфейсами. Наследование облегчает повторное использование кода.


Наследование реализуется с помощью ключевого слова extends. Расширяемый класс называется базовым (base), а расширяющий — производным (derived). Производный класс содержит все свойства и методы базового и может определять дополнительные члены.


3.1. Базовый класс


class Person {
  constructor(public name: string) {}

  public say(words: string) :void {
    console.log(`${this.name} says:${words}`);
  }
}

3.2. Производный класс


class Developer extends Person {
  constructor(name: string) {
    super(name);
    this.say("Learn TypeScript")
  }
}

const bytefer = new Developer("Bytefer");
// "Bytefer says:Learn TypeScript"

Класс Developer расширяет (extends) класс Person. Следует отметить, что класс может расширять только один класс (множественное наследование в TS, как и в JS, запрещено):





Однако мы вполне можем реализовывать (implements) несколько интерфейсов:


interface CanSay {
  say(words: string) :void
}

interface CanWalk {
  walk(): void;
}

class Person implements CanSay, CanWalk {
  constructor(public name: string) {}

  public say(words: string) :void {
    console.log(`${this.name} says:${words}`);
  }

  public walk(): void {
    console.log(`${this.name} walk with feet`);
  }
}

Рассмотренные классы являются конкретными (concrete). В TS также существуют абстрактные (abstract) классы.


4. Абстрактные классы


Классы, поля и методы могут быть абстрактными. Класс, определенный с помощью ключевого слова abstract, является абстрактным. Абстрактные классы не позволяют создавать объекты (другими словами, они не могут инстанцироваться (instantiate) напрямую):





Абстрактный класс — это своего рода проект класса. Подклассы (subclasses) абстрактного класса должны реализовывать всех его абстрактных членов:


class Developer extends Person {
  constructor(name: string) {
    super(name);
  }

  say(words: string): void {
    console.log(`${this.name} says ${words}`);
  }
}

const bytefer = new Developer("Bytefer");

bytefer.say("I love TS!"); // Bytefer says I love TS!

5. Видимость членов


В TS для управления видимостью (visibility) свойств и методов класса применяются ключевые слова public, protected и private. Видимость означает возможность доступа к членам за пределами класса, в котором они определяются.


5.1. public


Дефолтной видимостью членов класса является public. Такие члены доступны за пределами класса без каких-либо ограничений:


class Person {
  constructor(public name: string) {}

  public say(words: string) :void {
    console.log(`${this.name} says:${words}`);
  }
}

5.2. protected


Такие члены являются защищенными. Это означает, что они доступны только в определяющем их классе, а также в производных от него классах:





class Developer extends Person {
  constructor(name: string) {
    super(name);

    console.log(`Base Class:${this.getClassName()}`);
  }
}
const bytefer = new Developer("Bytefer"); // "Base Class:Person"

5.3. private


Такие члены являются частными (приватными). Это означает, что они доступны только в определяющем их классе:





Обратите внимание: private не делает членов по-настоящему закрытыми. Это всего лишь соглашение (как префикс _ в JS). Посмотрим на компилируемый код:


"use strict";
var Person = /** @class */ (function () {
    function Person(id, name) {
        this.id = id;
        this.name = name;
    }

    return Person;
}());

var p1 = new Person(28, "bytefer");

5.4. Частные поля


Реальные закрытые поля поддерживаются в TS, начиная с версии 3.8 (а в JS — с прошлого года):





Посмотрим на компилируемый код:


"use strict";
var __classPrivateFieldSet = // игнорировать соответствующий код;
var _Person_name;

class Person {
    constructor(name) {
        _Person_name.set(this, void 0);
        __classPrivateFieldSet(this, _Person_name, name, "f");
    }
}

_Person_name = new WeakMap();

const bytefer = new Person("Bytefer");

Отличия между частными и обычными полями могут быть сведены к следующему:


  • закрытые поля определяются с помощью префикса #;
  • областью видимости приватного поля является определяющий его класс;
  • в отношении частных полей не могут применяться модификаторы доступа (public и др.);
  • приватные поля недоступны за пределами определяющего их класса.

6. Выражение класса


Выражение класса (class expression) — это синтаксис, используемый дял определения классов. Как и функциональные выражения, выражения класса могут быть именованными и анонимными. В случае с именованными выражениями, название доступно только в теле класса.


Синтаксис выражений класса (квадратные скобки означают опциональность):


const MyClass = class [className] [extends] {
  // тело класса
};

Пример определения класса Point:


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

  public length() {
    return Math.sqrt(this.x * this.x + this.y * this.y);
  }
}

const p = new Point(3, 4);
console.log(p.length()); // 5

При определении класса с помощью выражения также можно использовать ключевое слово extends.


7. Общий класс


Для определения общего (generic) класса используется синтаксис <T, ...> (параметры типа) после названия класса:


class Person<T> {
  constructor(
    public cid: T,
    public name: string
  ) {}
}

const p1 = new Person<number>(28, "Lolo");
const p2 = new Person<string>("eft", "Bytefer");

Рассмотрим пример инстанцирования p1:


  • при создании объекта Person передается тип number и параметры конструктора;
  • в классе Person значение переменной типа T становится числом;
  • наконец, параметр типа свойства cid в конструкторе также становится числом.

Случаи использования дженериков:


  • интерфейс, функция или класс работают с несколькими типами данных;
  • в интерфейсе, функции или классе тип данных используется в нескольких местах.

8. Сигнатура конструктора


При определении интерфейса для описания конструктора может использоваться ключевое слово new:


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

new (x: number, y: number) называется сигнатурой конструктора (construct signature). Она имеет следующий синтаксис:


ConstructSignature: new TypeParametersopt ( ParameterListopt ) TypeAnnotationopt

TypeParametersopt, ParameterListopt и TypeAnnotationopt — это опциональный параметр типа, опциональный список параметров и опциональная аннотация типов, соответственно. Как применяется сигнатура конструктора? Рассмотрим пример:


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

class Point2D implements Point {
  readonly x: number;
  readonly y: number;

constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

const point: Point = new Point2D(1, 2); // Error

Сообщение об ошибке выглядит так:


Type 'Point2D' is not assignable to type 'Point'.
 Type 'Point2D' provides no match for the signature 'new (x: number, y: number): Point'.ts(2322)

Для решения проблемы определенный ранее интерфейс Point нужно немного отрефакторить:


interface Point {
  x: number;
  y: number;
}

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

Далее определяем фабричную функцию newPoint, которая используется для создания объекта Point, соответствующего конструктору входящего типа PointConstructor:


class Point2D implements Point {
  readonly x: number;
  readonly y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

function newPoint(
  pointConstructor: PointConstructor,
  x: number,
  y: number
): Point {
  return new pointConstructor(x, y);
}

const point: Point = newPoint(Point2D, 3, 4);

9. Абстрактная сигнатура конструктора


Абстрактная сигнатура конструктора была представлена в TS 4.2 для решения таких проблем, как:


type Constructor = new (...args: any[]) => any;

abstract class Shape {
  abstract getArea(): number;
}

const Ctor: Constructor = Shape; // Error
// Type 'typeof Shape' is not assignable to type 'Constructor'.
//  Cannot assign an abstract constructor type to a non-abstract
// constructor type.ts(2322)

Как видим, тип абстрактного конструктора не может присваиваться типу реального конструктора. Для решения данной проблемы следует использовать абстрактную сигнатуру конструктора:


type AbstractConstructor = abstract new (...args: any[]) => any;

abstract class Shape {
  abstract getArea(): number;
}

const Ctor: AbstractConstructor = Shape; // Ok

Далее определяем функцию makeSubclassWithArea для создания подклассов класса Shape:


function makeSubclassWithArea(Ctor: AbstractConstructor) {
  return class extends Ctor {
    #sideLength: number;

    constructor(sideLength: number) {
      super();
      this.#sideLength = sideLength;
    }

    getArea() {
      return this.#sideLength ** 2;
    }
  };
}

const Square = makeSubclassWithArea(Shape);

Следует отметить, что типы реальных конструкторов типам абстрактных конструкторов присваивать можно:


abstract class Shape {
  abstract getArea(): number;
}

class Square extends Shape {
  #sideLength: number;

  constructor(sideLength: number) {
    super();
    this.#sideLength = sideLength;
  }

  getArea() {
    return this.#sideLength ** 2;
  }
}

const Ctor: AbstractConstructor = Shape; // Ok
const Ctor1: AbstractConstructor = Square; // Ok

В заключение кратко рассмотрим разницу между типом class и типом typeof class.


10. Тип class и тип typeof class





На основе результатов приведенного примера можно сделать следующие выводы:


  • при использовании класса Person в качестве типа значение переменной ограничивается экземпляром этого класса;
  • при использовании typeof Person в качестве типа значение переменной ограничивается статическими свойствами и методами данного класса.

Следует отметить, что в TS используется система структурированных типов (structured type system), которая отличается от системы номинальных типов (nominal type system), применяемой в Java/C++, поэтому следующий код в TS будет работать без каких-либо проблем:


class Person {
  constructor(public name: string) {}
}

class SuperMan {
  constructor(public name: string) {}
}

const s1: SuperMan = new Person("Bytefer"); // Ok

Определение объекта с неизвестными свойствами


Приходилось ли вам сталкиваться с подобной ошибкой?





Для решения данной проблемы можно прибегнуть к помощи типа any:


consr user: any = {}

user.id = "TS001";
user.name = "Bytefer";

Но такое решение не является безопасным с точки зрения типов и нивелирует преимущества использования TS.


Другим решением может быть использование type или interface:


interface User {
  id: string;
  name: string;
}

const user = {} as User;
user.id = "TS001";
user.name = "Bytefer";

Кажется, что задача решена, но что если мы попробует добавить в объект свойство age?


Property 'age' does not exist on type 'User'

Получаем сообщение об ошибке. Что делать? Когда известны типы ключей и значений, для определения типа объекта можно воспользоваться сигнатурой доступа по индексу (index signatures). Синтаксис данной сигнатуры выглядит так:





Обратите внимание: типом ключа может быть только строка, число, символ или строковый литерал. В свою очередь, значение может иметь любой тип.





Определяем тип User с помощью сигнатуры доступа по индексу:


interface User {
  id: string;
  name: string;
  [key: string]: string;
}

При использовании сигнатуры доступа по индексу можно столкнуться с такой ситуацией:





  • Почему к соответствующему свойству можно получить доступ как с помощью строки "1", так и с помощью числа 1?
  • Почему keyof NumbersNames возвращает объединение из строки и числа?

Это объясняется тем, что JS неявно приводит число к строке при использовании первого в качестве ключа объекта. TS применяет такой же алгоритм.


Кроме сигнатуры доступа по индексу для определения типа объекта можно использовать встроенную утилиту типа Record. Назначение данной утилиты состоит в следующем:





type User = Record<string, string>

const user = {} as User;
user.id = "TS001"; // Ok
user.name = "Bytefer"; // Ok

В чем разница между сигнатурой доступа по индексу и утилитой Record? Они обе могут использоваться для определения типа объекта с неизвестными (динамическими) свойствами:


const user1: Record<string, string> = { name: "Bytefer" }; // Ok
const user2: { [key: string]: string } = { name: "Bytefer" }; // Ok

Однако в случае с сигнатурой тип ключа может быть только string, number, symbol или шаблонным литералом. В случае с Record ключ может быть литералом или их объединением:





Взглянем на внутреннюю реализацию Record:


/**
 * Construct a type with a set of properties K of type T.
 * typescript/lib/lib.es5.d.ts
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

Перегрузки функции


Знаете ли вы, почему на представленном ниже изображении имеется столько определений функции ref и зачем они нужны?





Рассмотрим пример простой функции logError, принимающей параметр строкового типа и выводящей сообщение об ошибке в консоль инструментов разработчика в браузере:


function logError(msg: string) {
  console.error(`Возникла ошибка: ${msg}`);
}

logError('Отсутствует обязательное поле.');

Что если мы хотим, чтобы данная функция также принимала несколько сообщений в виде массива?


Одним из возможных решений является использование объединения (union types):


function logError(msg: string | string[]) {
  if (typeof msg === 'string') {
    console.error(`Возникла ошибка: ${msg}`);
  } else if (Array.isArray(msg)) {
    console.error(`Возникли ошибки: ${msg.join('\n')}`);
  }
}

logError('Отсутствует обязательное поле.')
logError(['Отсутствует обязательное поле.', 'Пароль должен состоять минимум из 6 символов.'])

Другим решением является использование перегрузки функции (function overloading). Перегрузка функции предполагает наличие сигнатур перегрузки (overload signatures) и сигнатуры реализации (implementation signature).





Сигнатуры перегрузки определяют типы параметров функции и тип возвращаемого ею значения, но не содержат тела функции. Функция может иметь несколько сигнатур перегрузки:





В сигнатуре реализации для типов параметров и возвращаемого значения должны использоваться более общие типы. Сигнатура реализации также должна содержать тело функции:





После объединения сигнатур перегрузки и сигнатуры реализации мы имеет такую картину:





Обратите внимание: вызываются только сигнатуры перегрузки. При обработке перегрузки функции TS анализирует список перегрузок и пытается использовать первое определение. Если определение совпадает, анализ прекращается:





Если вызвать функцию с типом параметра, соответствующего сигнатуре реализации, возникнет ошибка:





Перегружаться могут не только функции, но и методы классов. Перегрузка метода — это техника, когда вызывается один и тот же метод класса, но с разными параметрами (разными типами параметров, разным количеством параметров, разным порядком параметров и т.д.). Конкретная сигнатура метода определяется в момент передачи реального параметра.


Рассмотрим пример перегрузки метода:


class Calculator {
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  add(a: string, b: number): string;
  add(a: number, b: string): string;
  add(a: string | number, b: string | number) {

if (typeof a === 'string' || typeof b === 'string') {
    return a.toString() + b.toString();
  }
    return a + b;
  }
}

const calculator = new Calculator();
const result = calculator.add('Bytefer', ' likes TS');

Надеюсь, что вы, как и я, нашли для себя что-то интересное.


Благодарю за внимание и happy coding!