- О TypeScript
- Типы данных (Data Types)
- Класс (Class)
- Интерфейс (Interface)
- Тип и интерфейс(Type vs interface)
- Дженерики (Generics)
TypeScript - это язык программирования, расширяющий возможности языка JavaScript.
TypeScript привносит несколько полезных возможностей:
- Наделяет JavaScript возможностью явного статического объявления типов данных.
- Дополняет возможности JavaScript инструментами ООП-разработки (появляются интерфейсы, модификаторы доступа, дженерики и так далее), таким образом TypeScript может считаться полноценным ООП-языком, позволяющим реализовывать большинство теоритических практик парадигмы, а значит может послужить альтернативой некоторым другим традиционным объектно-ориентированным языкам.
- В отличии от JavaScript - TypeScript имеет свою гибко настраиваемую систему модулей, которая использует технологию ES6-модули (
import,export). Заметим, что сам JavaScript вообще не имеет модульной системы. Нативный JavaScript может импортируется только в тэгах<script>, вставленных в HTML. При этом в NodeJS встроена более старах модульная система CommonJS-модулей (require,module.export), поддержка ES6-модулей начала появляться лишь недавно и только для последних версий.
TypeScript обратно совместим с JavaScript, поскольку TypeScript компилируется в JavaScript перед запуском программы. Таким образом, компилятор запускает TypeScript, производится статическая проверка типов, выдаются ошибки приведения типов, а если ошибок нет, то происходит компиляция в JavaScript и далее работа происходит обычный запуск скрипта на языке JavaScript.
TypeScript - компилируемый язык, который содержит все позможности языка JavaScript и расширяет их, а затем компилируется обратно в JavaScript.
JavaScript имеет неявную динамическую слабую типизацию. TypeScript позволяет делать её явной и статической в тех местах приложения, где это нужно. По умолчанию все переменные наделяются типом any, который позволяет иметь им любое значение. Таким образом, наличие TypeScript не обязывает разработчика указывать типы везде и всегда - он сам выбирает, где это нужно и насколько сильно. Также это позволяет плавно переписать любой JavaScript проект на TypeScript и не иметь 10000 ошибок в консоли при его подключении.
Поскольку TypeScript добавляет лишь инструменты типизации и после всех проверок компилируется в JavaScript, он не может вносить то, что нельзя было бы перевести в JavaScript.
Примитивные типы совпадают с теми, которые есть в JavaScript.
boolean— логическое значение (true,false).number— числовое значение (-1,3.9).string— строковое значение ("notes",'123').null— специальное значение null.undefined— специальное значение undefined.symbol— символ.
const symbol = Symbol('key');
const obj = {
[symbol]: 'value'
};
console.log(obj[sym]); // "value"
console.log(Symbol('key') === symbol); // false
console.log(obj[Symbol('key')]) // undefinedArray— массив (number[]).
let foo: number[];
foo = [1, 2, 3];
let bar: Array<string>;
bar = ['n', 'o', 't', 'e', 's'];any— произвольный тип (используется по умолчанию, если тип не указан).
let foo: any;
foo = 1;
foo = '';void— отсутствие конкретного значения (обычно возвращаемый тип функции).
const fn = (param: string): void => {
console.log(param);
// return отсутствует
}never— значение, которое никогда не наступит (обычно функции, возвращающие ошибку)
const throwError = (message: string): never {
throw new Error(message);
};Tuple— кортеж ([string, number]).
let foo: [string, number, boolean];
foo = ['notes', 17, true];
/* порядок важен */
foo = [17, 'notes', true]; // errorEnum— перечисление (более дружелюбные имена для множества числовых значений).
enum Visibility { Visible, Hidden }
const state: Visibility = Visibility.Visible; // 0object— непримитивный тип (non-primitive); любой тип, кроме примитивных.
let foo: object;
foo = { prop: 'value' };
foo = ['value'];
foo = () => console.log('notes');Object— любой JavaScript-объект (соответствует интерфейсуObject, имеющий методыtoString(),valueOf(),hasOwnProperty()и другие).
let obj: Object;
obj = {};interface Object {
toString(): string;
hasOwnProperty(v: string): boolean;
/* ... */
}Фактически, object и Object очень похожи, поскольку все непримитивные типы в JavaScript происходят от объектов, так в чём же отличие?
По сути, разница лишь в хайлайтинге:
const foo: Object = function (){}; // ок
const bar: Object = function (){}; // ок
foo. // покажет доступные методы
bar. // не покажет ничего
// но при этом
foo.toString() // нет ошибки
bar.toString() // нет ошибкиПроведя больше тестов, я выяснил, что разница в поведении между ними касается лишь типа Symbol.
const func1: Object = function (){}; // ок
const func2: Object = function (){}; // ок
func1() // ошибка TS: `This expression is not callable. Type 'Object' has no call signatures`
func2() // ошибка TS: `This expression is not callable. Type '{}' has no call signatures`
const arr1: Object = []; // ок
const arr2: object = []; // ок
arr1.fill(2); // `Property 'fill' does not exist on type 'Object'`
arr2.fill(2); // `Property 'fill' does not exist on type 'object'`
const symbol1: Object = Symbol(''); // ок
const symbol2: object = Symbol(''); // ошибка TS: `Type 'typeof symbol2' is not assignable to type 'object'`
const boolean1: Object = new Boolean(true); // ок
const boolean2: object = new Boolean(true); // ок
const promise1: Object = Promise.resolve(true); // ок
const promise2: object = Promise.resolve(true); // ок
promise1.then(() => {}); // `Property 'then' does not exist on type 'Object'`
promise2.then(() => {}); // `Property 'then' does not exist on type 'object'`{}— пустой тип, пустой объект. Обращение к его свойствам приведёт к ошибке, но остаётся возможность использовать все методыObject.
const foo = {};
if (true) {
foo.a = 'notes'; // Error: Property 'a' does not exist on type '{}'
}
console.log(foo.toString()); // '[object Object]'Стоит отметить, что тип {} задаётся автоматически, если присвоить пустой объект на этапе создания переменной вне зависимости от того, это let или const.
const a = {};
a.foo = 1; // ошибка TS `Property 'foo' does not exist on type '{}'`
let b = {};
b.bar = 2; // ошибка TS `Property 'bar' does not exist on type '{}'`
// можно задать тип явно
const a: {} = {}
// можно сбить пользователя с толку
const env: {} = { // ошибки при объявлении нет
OS: 'win32',
}
env.OS /* ошибка TS: `Property 'OS' does not exist on type '{}'`,
нет подсказок, какие свойства есть в объекте, но при этом значение `win32` возвращается
*/
// попытка обмануть компилятор тоже ни к чему не приведёт
env['OS'] /* ошибка TS: `Element implicitly has an 'any' type because expression of type '"OS"' can't be used to index type '{}'.
Property 'OS' does not exist on type '{}'.` */
// единсвенный способ избежать ошибки, отключить проверку следующей строки при помощи директивы `@ts-ignore`
// @ts-ignore
env.OS // 'win32' без ошибки TS
// тот же самый трюк с функцией, возвращающей `{}`
const getUserData = (): {} => ({ email: '17.max.starling@gmail.com' });
const user = getUserData();
user.email; /* вернёт '17.max.starling@gmail.com', но подсказки о том, что свойство `email` сущесвует, не будет,
а также будет ошибка TS `Property 'email' does not exist on type '{}'` */function a(): void {} // без ошибок
function b(): void {
return undefined; // всё ещё без ошибок (!)
}
function c(): void {
return (-1 + 1); // ошибка: `Type 'number' is not assignable to type 'void'`
}
function d(): void {
return void (-1 + 1); // нет ошибки, поскольку оператор `void` выполняет выражение, но возвращает `undefined
}Не путайте тип void из TypeScript и оператор void из JavaScript :)
Стоит отметить, что тип void на первый взгляд не сильно отличается от undefined в плане ошибок TS.
void function a(): undefined {} // ок
function a(): undefined {} // ок
function b(): undefined { return undefined; } // ок
function с(): undefined { return void true; } // окТак в чём же разница?
Разница есть, но она больше семантическая. Ещё раз, void означает, что значение не будет возвращено, а undefined означает, что будет возвращён undefined.
Это важно в опреденеии некоторых функций, например, Array.prototype.forEach:
declare function forEach<T>(array: T[], callback: (item: T) => undefined): void;
let numbers: number[] = [];
forEach([0, 1, 2], item => numbers.push(item)); // ошибка `Type 'number' is not assignable to type 'undefined'`Поскольку Array.prototype.push возвращает число, получаем ошибку, поскольку ожидался undefined.
Но если использовать void, то такой проблемы не будет, поскольку мы обещаем, что в реализации функции forEach не будет использовано возвращаемое значение callback-а.
Таким образом, судя по примеру выше, нельзя с точностью утверждать, что функция, которая возвращает void, действительно возвращает undefined - возвращаемое знаение может быть любым any.
declare function forEach<T>(array: T[], callback: (item: T) => void): void;
let numbers: number[] = [];
forEach([0, 1, 2], item => numbers.push(item)); // окfunction a(): never {
throw new Error();
}
function b(): never {
if (Math.random() > 0.5) {
throw new Error(); // ошибка `A function returning 'never' cannot have a reachable end point.`
}
}
function b(): never | void {
if (Math.random() > 0.5) {
throw new Error(); // нет ошибки
}
}Перечисления - это один из немногих типов в TypeScript, который имеет представление в JavaScript в виде объекта, который можно использовать в ходе выполнения программы.
enum Visibility { Visible, Hidden }
console.log(Visibility);
/* {
"0": "Visible",
"1": "Hidden",
"Visible": 0,
"Hidden": 1
} */
console.log(Visibility[0]) // "Visible"
console.log(Visibility.Hidden) // 1 Получили объект, в котором строки соответствуют числовым индексам и наоборот.
Выше показано, что происходит со значениями перечислений по умолчанию, теперь зададим значения явно.
enum Color {
RED = 'red',
YELLOW = 'yellow',
GREEN = 'green'
}
console.log(Color);
/* {
"RED": "red",
"YELLOW": "yellow",
"GREEN": "green"
} */
console.log(Color.YELLOW) // 'yellow'
console.log(Color['yellow']) // `undefined` и ошибка: `Property 'yellow' does not exist on type 'typeof Color'. Did you mean 'YELLOW'?`Перечислениями также можно манипулировать при помощи Object-методов. Это может быть полезно при переборе всех допустимых значений, например, при написании валидации.
Object.values(Color) // ["red", "yellow", "green"]
Object.keys(Color) // ["RED", "YELLOW", "GREEN"]
Object.entries(Color) // [["RED", "red"], ["YELLOW", "yellow"], ["GREEN", "green"]] Классом (англ. class) называют конструктор (строитель, генератор, создатель) объектов. В теле класса содержится вся информация, которую будет содержать объект после создания.
- Объявление класса
- Создание объектов с помощью класса
- Задание свойств в теле класса
- Передача параметров класса через конструктор и ключевое слово
this - Валидация параметров класса
- Задание методов класса
- Потеря и привязка контекста
thisв классе
Имена классам даются с большой буквы.
/* объявление двух классов Bird и Person */
class Bird {}
class Person {}Для создания объекта через класс или функцию-конструктор используется оператор new.
/* создание двух объектов при помощи класса Bird */
const dove = new Bird();
const magpie = new Bird();
/* создание объекта при помощи класса Person */
const guest = new Person();/* задание свойств `name` и `age` в теле класса Person */
class Person {
name: string = "He/She";
age: number = 0;
}
const p = new Person();
console.log(p); // Person { name: "He/She", age: 0 }Если не задать тип и значение свойству, то будет выдано предупреждение:
/* задание свойств `name` и `age` в теле класса Person */
class Person {
name: string = "He/She";
age; // TS: Member 'name' implicitly has an 'any' type.
}
const p = new Person();
console.log(p); // Person { name: "He/She" }Класс может принимать параметры и использовать их в качестве аргументов внутри конструктора при создании объекта. Как и параметры функции, параметры класса могут быть обязательными и не обязательными (объявляются с ?).
Чтобы присвоить значение полю класса, нужно использовать ключевое слово this, которое представляет собой контекст, то есть всё, что касается создания или использования текущего объекта.
/* задание свойств `name` и `age` в теле класса Person */
class Person {
name: string;
age: number;
constructor(name: string, age?: number) {
this.name = name;
this.age = age || 0;
}
}
/* в примере ниже будет ошибка, так как параметр `name` ялвяется обязательным
const someone = new Person(); // TS: Expected 1-2 arguments, but got 0. An argument for 'name' was not provided.
/* инициализация класса с передачей обязательного параметра */
const max = new Person("Max");
console.log(max); // Person { name: "Max", age: 0 }
/* инициализация класса с передачей обязательного и необязательного параметров */
const dan = new Person("Dan", 21);
console.log(dan); // Person { name: "Dan", age: 21 }Можно налагать некоторые условия на параметры класса и проверять выполнение этих *условий при создании объекта в конструкторе, то есть валидировать параметры класса. При несоблюдении заданных условий будет выдаваться ошибка.
/* задание свойств `name` и `age` в теле класса Person */
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
/* валидация по параметру `name` */
if (name.length < 2) {
throw new Error("Name must be at least 2 characters long");
}
/* валидация по параметру `age` */
if (age < 18) {
throw new Error("Person must be 18 years of age or older");
}
this.name = name;
this.age = age || 0;
}
}
/* в примере ниже будет ошибка из-за непрошедшего валидацию поля `name` */
const g = new Person("G", 27); // [ERR]: Name must be at least 2 characters long
/* в примере ниже будет ошибка из-за непрошедшего валидацию поля `age` */
const yo = new Person("Yo", 10); // [ERR]: Person must be 18 years of age or older
/* в примере ниже ошибок нет */
const ns = new Person("NS", 39);Метод в классе можно объявить тремя способами, которые практически ничем не отличаются.
class Animal {
name: string;
constructor(name?: string) {
this.name = name || "It";
}
/* объявление метода как свойства, значением которого является функция `function` (`Function Expression`) */
walk = function() {
console.log(this);
}
/* объявление метода как свойства, значением которого является стрелочная функция (`Arrow Function Expression`) */
fly = () => {
console.log(this);
}
/* объявление метода как метода класса */
swim() {
console.log(this);
}
}
const duck = new Animal("Duck");
duck.swim(); // Animal { name: "Duck" }
duck.walk(); // Animal { name: "Duck" }
duck.fly(); // Animal { name: "Duck" }Как видно на примере выше, если использовать обращение к методу через . и его вызов () в одном выражении, то все три объявления работают одинаково.
Попробуем теперь присвоить каждый метод класса в отдельные переменные и вызвать получившиеся функции спустя некоторое время.
Чаще всего такое необходимо при передаче метода класса как фунцкии обратного вызова (callback) куда-либо.
class Animal {
name: string;
constructor(name?: string) {
this.name = name || "It";
}
walk = function() {
console.log(this);
}
fly = () => {
console.log(this);
}
swim() {
console.log(this);
}
}
const duck = new Animal("Duck");
/* потеря контекста */
const swim = duck.swim;
swim(); // undefined
/* контекст не потерялся */
const walk = duck.walk;
walk(); // Animal { name: "Duck" }
/* контекст утерян */
const fly = duck.fly;
fly(); // undefinedВ данном примере разрывается связь классом и его методом, связь между . и (), что приводит к потере контекста.
Из примера выше видно, что потеряли контекст методы swim() и walk = function() {}, поскольку нестрелочные функции могут иметь свой контекст.
В то же время стрелочная функция не может иметь контекст и присваивать его ей тоже нельзя. Вместо этого контекст задаётся ей в момент объявления с уровня выше. В данном случае для fly = () => {} контекст взят из класса Animal, поэтому её связь с классом будет сохраняться в любом случае.
Сущесвует несколько способов решения проблемы
- Никогда не разрывать связь между обращением к методу класса через
.и его вызовом через()при присвоении в другую переменную.
const duck = new Animal("Duck");
const swim = () => duck.swim();
const walk = () => duck.walk();
const fly = () => duck.fly();
swim(); // Animal { name: "Duck" }
walk(); // Animal { name: "Duck" }
fly(); // // Animal { name: "Duck" }- Явно привязать контекст в конструкторе класса через
bind
class Animal {
name: string;
constructor(name?: string) {
this.name = name || "It";
/* явная привязка контекста через `bind` */
this.walk = this.walk.bind(this);
this.swim = this.swim.bind(this);
}
walk = function() {
console.log(this);
}
fly = () => {
console.log(this);
}
swim() {
console.log(this);
}
}
const duck = new Animal("Duck");
const swim = duck.swim;
const walk = duck.walk;
const fly = duck.fly;
swim(); // Animal { name: "Duck" }
walk(); // Animal { name: "Duck" }
fly(); // Animal { name: "Duck" }- Всегда использовать только стрелочные функции в качестве методов класса в тех случаях, когда есть риск потери контекста.
Доказательство того, что стрелочной функции нельзя привязать контекст:
class Animal {
name: string;
constructor(name?: string) {
this.name = name || "It";
/* явная привязка контекста пустого объекта через `bind` всем трём методам */
this.walk = this.walk.bind({});
this.swim = this.swim.bind({});
this.fly = this.fly.bind({});
}
walk = function() {
console.log(this);
}
fly = () => {
console.log(this);
}
swim() {
console.log(this);
}
}
const duck = new Animal("Duck");
const swim = duck.swim;
const walk = duck.walk;
const fly = duck.fly;
swim(); // {}
walk(); // {}
fly(); // Animal { name: "Duck" }Интерфейс (Interface) является абстрактным описанием того, что должен включать в себя объект, но не содержит никакой реализации, то есть не содержит методов и полей свойств - только используемые в них типы.
Сравните
class Person {
name: string = "He/she";
eat(food: string): void {
console.log(this.name, "eats", this.food);
}
}interface IPerson {
name: string;
eat(food: string): void;
}То есть интерфейс представляет собой лишь схему
Интерфейс в TypeScript является виртуальной структурой: он существует только в контексте языка. Компилятор при помощи интерфейсов и прочих способов типизации проводит проверку типов, а затем переводит код в JavaScript, куда интерфейсы не попадают.
Интерфейсы, как и классы, именуют с большой буквы. Часто можно встретить заглавную I в начале, чтобы разрешить конфликт имён классов и интерфейсов.
interface IAuthor {
id: string;
username: string;
}
interface IArticle {
id: string;
title: string;
description?: string;
getAuthor: () => Author;
}Использование интерфейсов похоже на утиную типизацию.
interface IDuck {
quack(): void;
}
const obj: IDuck = {
/* может квакать, значит утка */
quack(): void {
console.log('quack!');
},
};Абстрактный класс (Abstract class) содержит некоторые абстрактные методы, которые должны быть реализованы его наследниками. Помимо абстрактных методов, в нём могут также содержаться и обычные методы. Они характеризуют поведение по умолчанию и их реализовывать не обязательно.
abstract class Duck {
abstract eat(): void; // утки в разных странах могут есть разную еду
makeSound(): void {
console.log('quack!'); // но все они издают похожий звук
}
}
class Mallard extends Duck {
eat() { /* ... */ }
// дикая утка наследует метод quack от абстрактного родительского класса
}Интерфейс, в отличие от любого класса, вообще не может содержать реализации. Поэтому от него не наследуют (extends), а его реализуют (implements).
interface IDuck {
eat(): void;
makeSound(): void;
}
class Mallard implements IDuck {
eat() { /* ... */ }
makeSound() { /* ... */ }
};В TypeScript есть возможность наследовать интерфейс от класса.
class Fish {
private age(): string;
swim(): void;
}
interface IFlyingFish extends Fish {
fly: () => void;
};Такая возможность связана с тем, что в TypeScript (как и в JavaScript) можно создать объект без класса.
class Human {
sex: string;
constructor(sex: string) {
this.sex = sex;
}
run():void {
console.log('run');
}
}
/* объекты human1 и human2 реализуют один и тот же интерфейс */
const human1 = new Human('male');
const human2 = {
sex: 'male',
run():void {
console.log('run');
}
};В этом и есть смысл: можно взять интерфейс класса и использовать его.
interface IHuman extends Human {}
let human3: IHuman;
human3 = { ...human2 };Более того, мы можем расширить этот интерфейс. И таким образом заменить наследование композицией (Composite Reuse Principle), реализуя расширенный интерфейс вместо переопределения методов родительского класса.
class Bird { /* ... */ };
interface IFlyingBird extends Bird {
fly: () => void;
}
class FlyingBird implements IFlyingBird { /* ... */ }Можно также найти применение наследованию интерфейса от класса при использовании Generics.
class Translator<From, To> { /* ... */ }
interface EngRusTranslator extends Translator<Russian, English> {}
const translate = (translator: EngRusTranslator) => { /* ... */ }Интерфейсы наследуют всё, включая приватные и защищённые члены базового класса.
Если базовый класс содержит приватные или защищённые свойства и методы, то наследующий от него интерфейс может быть реализован только базовым классом или его наследником.
И тип (Type), и интерфейс (Interface) описывают объекты, так что в простых случаях будут работать одинаково.
type User = { id: string; name: string }interface User { id: string; name: string }Различия:
- Интерфейсы поддерживают наследование (англ.
inheretance) черезextends, позволяющее создавать новый тип, дополняя уже существующий:
interface Customer extends User { company: string }- Типы поддерживают пересечение (англ.
intersection) с помощью&(AND), позволяющее комбинировать несколько типов в один, создавая новый тип:
type Customer = User & { company: string }- Типы также поддерживают объединение (англ.
union) с помощью|(OR):
type A = { foo: string }
type B = { bar: string }
type C = A | B // либо тип А, либо тип Б- Интерфейсы поддерживают слияние деклараций (англ.
Declaration Merging), типы - не поддерживают (будет ошибка):
interface User { id: string }
interface User { name: string }
const user: User = {
id: 1,
name: "John",
};type User = { id: string }
type User = { name: string } // ❌ Error: Duplicate identifier 'User'- Типы могут работать с примитивами, интерфейсы - не могут:
type Pet = 'cat' | 'dog'
type Pets = `{Pet}s` // 'cats' | 'dogs'
type ID = string | number- Интерфейсы подходят для имплементации классов (англ.
implementation) черезimplements:
interface Person {
name: string;
greet(): void;
}
class User implements Person {
name: string;
greet() { console.log('Hi!'); }
}- Определяем типы:
type User = {
name: string;
age: number;
};
type Admin = {
name: string;
permissions: string[];
};- Создаём маппинг
type RoleMap = {
user: User;
admin: Admin;
};- Создаём функцию и вызываем
function getData<T extends keyof RoleMap>(role: T): RoleMap[T] {}
const user = getData("user"); // вернёт тип User
const admin = getData("admin"); // вернёт тип AdminЕщё пример
type ResponseMap = {
success: { status: "ok"; data: any };
loading: { status: "loading" };
error: { status: "error"; message: string };
};
function getResponse<T extends keyof ResponseMap>(status: T): ResponseMap[T] {}
const successResponse = getResponse("success"); // вернется тип { status: "ok"; data: any }
const errorResponse = getResponse("error"); // вернется тип { status: "error"; message: string }