Skip to main content

TypeScript в деталях

Источник.

Спасибо Денису Улесову за помощь в редактировании материала.

Обратите внимание: предполагается, что вы имеете некоторый опыт работы с TypeScript. Если нет, рекомендую начать с:

T, K и V в дженериках / Generics


T называется параметром общего типа (generic type parameter). Это заменитель (placeholder) настоящего (actual) типа, передаваемого функции.

Суть такая: берем тип, определенный пользователем, и привязываем (chain) его к типу параметра функции и типу возвращаемого функцией значения.


Так что все-таки означает T? T означает тип (type). На самом деле, вместо T можно использовать любое валидное название. Часто в сочетании с T используются такие общие переменные, как K, V, E и др.

  • K представляет тип ключа объекта;
  • V представляет тип значения объекта;
  • E представляет тип элемента.

Разумеется, мы не ограничены одним параметром типа - их может быть сколько угодно:


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


Утилиты типа / Utility types

Утилиты типа (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. Capitalize<StringType>

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


15. Uncapitalize<StringType>

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


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

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

Классы / Classes

В объектно-ориентированных языках программирования класс - это шаблон (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

Связанные типы / Mapped types

Приходилось ли вам использовать вспомогательные типы Partial, Required, Readonly и Pick?


Интересно, как они реализованы?

Регистрация пользователей является распространенной задачей в веб-разработке. Определим тип User, в котором все ключи являются обязательными:

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

Как правило, зарегистрированные пользователи могут модифицировать некоторые данные о себе. Определим новый тип PartialUser, в котором все ключи являются опциональными:

type PartialUser = {
name?: string
password?: string
address?: string
phone?: string
}

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

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

Получаем много дублирующегося кода:


Как можно уменьшить его количество? Ответ - использовать сопоставленные типы, которые являются общими типами (generic types), позволяющими связывать тип исходного объекта с типом нового объекта.


Синтаксис связанных типов:


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


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

Синтаксис основных связанных типов:

{ [ 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 }

Несколько примеров:


Переопределим тип PartialUser с помощью связанного типа:

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

type PartialUser = MyPartial<User>

MyPartial используется для сопоставления типов User и PartialUser. Оператор keyof возвращает все ключи типа в виде объединения (union type). Тип переменной P меняется на каждой итерации. T[P] используется для получения типа значения, соответствующего атрибуту типа объекта.

Демонстрация потока выполнения MyPartial:


TS 4.1 позволяет повторно связывать ключи связанных типов с помощью ключевого слова as. Синтаксис выглядит так:

type MappedTypeWithNewKeys<T> = {
[K in keyof T as NewKeyType]: T[K]
// ^^^^^^^^^^^^^
}

Тип NewKeyType должен быть подтипом объединения string | number | symbol. as позволяет определить вспомогательный тип, генерирующий соответствующие геттеры для объектного типа:

type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

interface Person {
name: string
age: number
location: string
}

type LazyPerson = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// getLocation: () => string
// }

Поскольку тип, возвращаемый keyof T может содержать тип symbol, а вспомогательный тип Capitalize требует, чтобы обрабатываемый тип был подтипом string, фильтрация типов с помощью оператора & в данном случае является обязательной.

Повторно связываемые ключи можно фильтровать путем возвращения типа never:

// Удаляем свойство 'kind'
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}

interface Circle {
kind: 'circle'
radius: number
}

type KindlessCircle = RemoveKindField<Circle>
// type KindlessCircle = {
// radius: number
// }

Условные типы / Conditional types

Приходилось ли вам использовать утилиты типов Exclude, Extract, NonNullable, Parameters и ReturnType?

Все эти утилиты основаны на условных типах (conditional types):

Здесь представлена лишь часть процесса


Краткая справка:


Названные утилиты используются для следующих целей:

  • Exclude - генерирует новый тип посредством исключения из UnionType всех членов объединения, указанных в ExcludedMembers;
  • Extract - генерирует новый тип посредством извлечения из Type всех членов объединения, указанных в Union;
  • NonNullable - генерирует новый тип посредством исключения null и undefined из Type;
  • Parameters - генерирует новый кортеж (tuple) из типов параметров функции Type;
  • ReturnType - генерирует новый тип, содержащий тип значения, возвращаемого функцией Type.

Примеры использования этих утилит:


Синтаксис условных типов:

T extends U ? X : Y

T, U, X и Y - заменители типов (см. выше). Сигнатуру можно понимать следующим образом: если T может быть присвоен U, возвращается тип X, иначе возвращается тип Y. Это чем-то напоминает тернарный оператор в JavaScript.


Как условные типы используются? Рассмотрим пример:

type IsString<T> = T extends string ? true : false;

type I0 = IsString<number>; // false
type I1 = IsString<"abc">; // true
type I2 = IsString<any>; // boolean
type I3 = IsString<never>; // never

Утилита IsString позволяет определять, является ли действительный тип, переданный в качестве параметра типа, строковым типом. В дополнение к этому, с помощью условных типов и условных цепочек (conditional chain) можно определять несколько типов за один раз:


Условная цепочка похожа на тернарные выражения в JS:


Вопрос: что будет, если передать TypeName объединение (union)?

// "string" | "function"
type T10 = TypeName<string | (() => void)>;
// "string" | "object" | "undefined"
type T11 = TypeName<string | string[] | undefined>;

Почему типы T10 и T11 возвращают объединения? Это объясняется тем, что TypeName - это распределенный (distributed) условный тип. Условный тип называется распределенным, если проверяемый тип является "голым" (naked), т. е. не обернут в массив, кортеж, промис и т. д.


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

T extends U ? X : Y
T => A | B | C
A | B | C extends U ? X : Y =>
(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)

Рассмотрим пример:


Если параметр типа обернут в условный тип, он не будет распределенным, поэтому процесс не разбивается на отдельные ветки.

Рассмотрим поток выполнения (execution flow) встроенной утилиты Exclude:

type Exclude<T, U> = T extends U ? never : T;
type T4 = Exclude<"a" | "b" | "c", "a" | "b">

("a" extends "a" | "b" ? never : "a") // => never
| ("b" extends "a" | "b" ? never : "b") // => never
| ("c" extends "a" | "b" ? never : "c") // => "c"

never | never | "c" // => "c"

Пример реализации утилиты с помощью условных и связанных (mapped, см. Заметка о Mapped Types и других полезных возможностях современного TypeScript) типов:


type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

type NonFunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

interface User {
id: number;
name: string;
age: number;
updateName(newName: string): void;
}

type T5 = FunctionPropertyNames<User>; // "updateName"
type T6 = FunctionProperties<User>; // { updateName: (newName: string) => void; }
type T7 = NonFunctionPropertyNames<User>; // "id" | "name" | "age"
type T8 = NonFunctionProperties<User>; // { id: number; name: string; age: number; }

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

Пересечения / 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.

Шаблонные литеральные типы / 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>;

Оператор keyof

Приходилось ли вам использовать утилиты типов Partial, Required, Pick и Record?


Внутри всех этих утилит используется оператор keyof.

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

const user = {
id: 666,
name: "bytefer",
}
const keys = Object.keys(user); // ["id", "name"]

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

type 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, key) {
return obj[key];
}
const user = {
id: 666,
name: "bytefer",
}
const userName = getProperty(user, "name");

Функция getProperty принимает 2 параметра: объект (obj) и ключ (key), и возвращает значение объекта по ключу.

Перенесем данную функцию в TS:


В сообщениях об ошибках говорится о том, что obj и key имеют неявные типы any. Для решения проблемы можно явно определить типы параметров:


Получаем другую ошибку. Для правильного решения следует использовать параметр общего типа (generic) и keyof:

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

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

При отсутствии ключа TS генерирует следующее сообщение об ошибке:


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


Рассмотрим поток выполнения (execution flow) утилиты Partial:


/**
* Делает все свойства T опциональными.
* typescript/lib/lib.es5.d.ts
*/
type Partial<T> = {
[P in keyof T]?: T[P];
};

Оператор typeof

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

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


Объект man - это обычный объект JS. Для определения его типа в TS можно использовать type или interface. Тип объекта позволяет применять встроенные утилиты типов, такие как Partial, Required, Pick или Readonly, для генерации производных типов.

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

type Person = typeof man;

type Address = Person["address"];

Person["address"] - это тип индексированного доступа (indexed access type), позволяющий извлекать тип определенного свойства (address) из другого типа (Person).

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

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


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


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

Другим примером использования typeof является получение типа функции (функция в JS - это тоже объект). После получения типа функции можно воспользоваться встроенными утилитами типов ReturnType и Parameters для получения типа возвращаемого функцией значение и типа ее параметров:


4. Получение типа класса


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


6. Получение более точного типа

Использование typeof в сочетании с утверждением const (const assertion), представленным в TS 3.4, позволяет получать более точные (precise) типы:


Ключевое слово 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;

Ключевое слово 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);

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

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


Для решения данной проблемы можно прибегнуть к помощи типа 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;
};

Перегрузки функции / Function overloads

Знаете ли вы, почему на представленном ниже изображении имеется столько определений функции 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');