Первый взгляд на Typescript
В силу того, что я присоединился к проекту, на котором команда использует TS, последние несколько месяцев я работаю именно с этим, безусловно, очень интересным языком.
Разумеется, я воспринял эту возможность с большим энтузиазмом! Столько разговоров о TypeScript в сообществе, столько библиотек и кода написано! Строгая типизация - это то, чего нам так не хватает в мире JavaScript! Или нет? Может нам не хватает чего-то ещё? А что вообще TS нам предлагает?
Всё же хотелось бы понимать мотивацию, прежде, чем использовать новый язык в реальных проектах.
Мне кажется, что ответ на главный вопрос "Зачем?" я нашёл в первых абзацах документации TypeScript
The goal of TypeScript is to be a static typechecker for JavaScript programs - in other words, a tool that runs before your code runs (static) and ensures that the types of the program are correct (typechecked).
Что ж, не плохо, значит цель это просто статический анализ кода. Давайте примем это за точку отсчета и не будем ждать от языка большего.
Помимо прочего TS декларирует полную поддержку классов, а так же интерфейсы.
Стоп, что?
Интерфейсы и классы?!?!
Теперь это выглядит как полноценный объектно-ориентированный язык программирования! Неужели я смогу использовать наследование, полиморфизм и паттерны ОПП? Вообще - да! На практике же я неожиданно столкнулся с тем, что сообщество не делает этого!
Я задавал вопросы у себя в команде, задавал вопросы в социальных сетях и лучший ответ который я получил был:
Class is just an overhead to be data descriptor, because typescript uses structural typing.
Хм, честно говоря, непонятно почему это оверхед?
Давайте рассмотрим пример.
Допустим, у нас есть две разные сущности с сервера, которые необходимо отрендерить в UI.
Первая сущность это User:
{
"firstName": "Jhon",
"lastName": "Doe",
"address": "London, UK"
}
А вторая это Организация:
{
"name": {
"fullName": "MissionInmossible Incorparated.",
"shortName": "MI Inc."
}
"address": {
"legal": "M60 2LA Manchester, UK",
"branches": ["London, UK"]
}
}
И нам нужно отрендерить обе эти сущности в одном компоненте, например так:
В первую очередь, я должен оговориться что, естесственно, я не хочу создавать 2 почти одинаковых компонента - помним о старике Оккаме.
Итак, в моей голове есть несколько решений данной проблемы:
1. Маппинг данных
Определим интерфейсы для наших сущностей и создим отдельный интерфейс для компонента, а затем замаппим интерфейс:
interface IUser {
firstName: string;
lastName: string;
address: string;
};
interface IOrganization {
name: {
fullName: string;
shortName: string;
};
address: {
legal: string;
branches: Array<string>;
}
};
interface IUIListItem {
name: string;
address: string;
}
interface IUIListProps {
items: Array<IUIListItem>
};
function UIList({items}: IUIListProps) {
}
const user: IUser = {
"firstName": "Jhon",
"lastName": "Doe",
"address": "London, UK"
};
const organization: IOrganization = {
"name": {
"fullName": "MissionInmossible Incorparated.",
"shortName": "MI Inc."
}
"address": {
"legal": "M60 2LA Manchester, UK",
"branches": ["London, UK"]
}
}
const items: Array<IUIListItem> = [
{
name: `${user.firstName} ${user.lastName}`,
address: user.address
},
{
name: organization.name.fullName,
address: organization.address.legal
}
];
<UIList items={items}/>
Именно такой подход я вижу в большинстве кода в интернете....возможно не там смотрю. У такого подхода есть очевидные и очень крупные недостатки.
Во-первых, лишние затраты по времени и памяти. Если список элементов достаточно большой, а нам надо смаппить каждый из них, операция может стать достаточно дорогой.
Во-вторых, если появится новая сущность - придётся как-то маппить и её.
В-третьих, и это самое больное, если поменяется структура сущности, нужно будет править ещё и в скриптах-мапперах. Это же работает и в другую сторону, если поменяется компонент.
2. Защита
Хорошо, давайте не будем создавать отдельный интерфейс, а передадим наши сущности напрямую в компонент через union оператор.
Часть кода с определением интерфейсов и данных осталась в примере выше.
Здесь я, для наглядности, использую компонент UIListItem.
interface IUIListItemProps {
item: IUser | IOrganization
};
function UIListItem({item}: IUIListItemProps) {
let name = '';
if (item.name) {
name = item.name.fullName;
} else {
name = item.firstName;
}
}
<UIListItem item={user}/>
<UIListItem item={organization}/>
Если я всё правильо понял, то примерно так (хоть в этом примере и топорно, но более наглядно) и выглядит механизм защиты
Что ж, этот подход мне нравится уже больше. Как минимум, мне не нужно создавать дополнительные объекты. И всё же огромная проблема этого подхода в невероятной связности данных и представлений. Если вдруг нам нужно будет добавить ещё одну сущность в продукт - нам придётся дописывать все компоненты которые будут её использовать....
Именно из проблемы связности и вытекает моё третье решение
3. Старый добрый ООП
Я упоминал ООП и паттерны проектирования, давайте посмотрим, может получится использовать что-нибудь из того что уже было придумано много-много лет назад?
Например разделим компоненты и данные через интерфейс. Для этого сущности определим как классы и реализуем в них этот интерфейс.
interface IUIListItem {
getName: () => string;
getAddress: () => string;
}
class User implements IUIListItem {
firstName: string;
lastName: string;
address: string;
constructor() {
this.firstName = '';
}
getName() {
return this.firstName;
}
};
class Organization implements IUIListItem {
name: {
fullName: string;
shortName: string;
};
address: {
legal: string;
branches: Array<string>;
}
constructor() {
this.name = {
fullName: 'dasdasdas'
}
}
getName() {
return this.name.fullName;
}
};
interface IUIListItemProps {
item: IUIListItem
};
function UIListItem({item}: IUIListItemProps) {
let name = item.getName();
}
<UIListItem item={user}/>
<UIListItem item={organization}/>
Я не стал имплементировать оба метода, думаю что и без этого очевидно, что в этом случае чтобы ни происходило с данными, на компонент это совершенно никак не повлияет. Всё в лучших традициях ООП - компонент предоставляет интерфейс работы с ним, кто хочет - имплементируйте!
Даже если появится новая сущность, всё что нужно сделать - имплементировать в ней интерфейс!
Если поменяется интерфейс - ок, придётся поменять что-то в классах, но сами данные, тем не менее, мы не затронем!
Лично мне этот путь ближе всего, но я лишь в начале пути.
Возможно у вас, как и у меня, появился резонный вопрос: "Подождите, подождите, но чтобы использовать методы класса, нам нужно создать объект через new
. А это предполагает, что мы должны как-то проинициализировать переменные внутри класса. Вот он overhead, так?"
С одной стороны вообще не так! Данные не берутся из ниоткуда! Мы их либо получаем из запроса, либо из базы, либо создаём сами. Во всех этих случаях мы в состоянии создать объект через new
.
Но, с другой стороны, именно в Typescript нам придётся делать это руками миллионы раз! И это самая большая загадка для меня!
Что не так с экземплярами класса?
Например, я хочу получить данные из запроса
const result = await fetch(`some-url`);
class MyData() {
name: string;
constructor() {
this.name = '';
}
}
const data: MyData = await result.json();
В результате этого примера я хочу получить в переменной data
экземпляр класса MyData с заполенными полями из ответа сервера. По факту же я получаю просто объект из ответа. Обратите внимание, что data instanceof MyData
будет false
.
Я согласен что для того, чтобы заполнить поля, возможно требуются некоторые декораторы, которые будут маппить json и поля класса, но думаю это не так сложно реализовать в языке! Но почему мне не возвращается экземпляр класса???? Пусть даже и с не заполненными или неверными данными. Если я определил мою переменную как экземпляр класса я и хочу получить экземпляр класса!
Это очень странное поведение, которое, по сути, убивает все зачатки ООП в Typescript
Да, я знаю историю развития Javascript и Typescript и понимаю все сложности, связанные с классами и т.д. Но может тогда вообще нам этот функционал не нужен и мы будем использовать только статический анализатор? Но зачем тогда вообще отдельный язык? На эти вопросы я сам себе пока не могу ответить.
Магия
И последнее, о чём я бы хотел упомянуть вскольз - это утилитарные типы. При первом взгляде они выглядят как абсолютная магия. Совершенно непонятно, что они делают и зачем нужны. Чуть позже я стал замечать, что их используют как раз как некую альтернативу паттернам проектирования. Вместо создания интерфейсов и фабрик, манипулируют и комбинируют типы с помощью утилитарных типов. Выглядит довольно странно, честно говоря. Хотя некоторое удобство в этом наблюдается.
Выводы
Спустя несколько месяцев работы с Typescript я всё ещё не могу для себя решить нравится ли он мне и буду ли я его использовать в своём следующем pet проекте. Мне очень нравится статический анализатор и строгая типизация. Но они мне и в Java нравятся. При этом в Java есть полноценное ООП, а в Typescript только с натяжкой.
С другой стороны, надо сравнивать не с Java, а с Javascript. И вот тут вопрос глубже и интереснее, особенно в свете явной тяги последнего в функциональному программированию.
Как я и сказал, я лишь в начале пути, возможно ещё через пару месяцев я буду от Typescript в совершенном восторге.