TypeScript: худший лучший язык программирования
На конференции FrontedConf 2021 Андрей Старовойт показал плюсы и минусы TypeScript. Если вы сомневаетесь, стоит ли его использовать — эта статья для вас, чтобы вы смогли для себя всё решить. Если вы уже любите и используете TypeScript, то надеюсь, вам тоже будет интересно.
Все преимущества и недостатки языка описаны, конечно, через призму опыта Андрея. Несмотря на то, что последние 7 лет он работает в компании JetBrains над продуктом WebStorm на Java Kotlin, пишет он и на TypeScript. Попутно много смотрит на код других людей, пытаясь понять, что с ним можно сделать внутри WebStorm и почему типы выбились неправильно. А также — какие инспекции можно применить так, чтобы люди стали счастливы, а их код — лучше.

Самый неочевидный аспект TypeScript — в нем нет синтаксического сахара (ну почти). Вместо этого язык реализует типовую систему для JavaScript. Часто говорят, что это минус TypeScript: «Мы могли бы сделать JavaScript гораздо более эффективным в плане написания кода, добавив каких-нибудь магических конструкций, которые компилировались бы в эффективный JavaScript!». Но команда TypeScript так не делает.
Точнее, поначалу они попробовали, добавив namespace и enum. Но сейчас это считается не очень удачными экспериментами, и TypeScript больше не добавляет новых фич, связанных с синтаксическим сахаром. Во многом это обусловлено тем, что JavaScript активно развивается, а TypeScript — это надстройка над JavaScript. То есть мы и так автоматически получаем все новые синтаксические конструкции из спецификации языка.
Теперь давайте посмотрим, из чего состоит TypeScript, и какие могут быть сложности с каждой из его особенностей.
Типы TypeScript
Достаточно знать несколько типов?
Типы — это основная концепция, связанная с TypeScript и то, ради чего этот язык задумывался. Если открыть цели команды TypeScript, то там явно написано: они разрабатывают статическую типовую систему для JavaScript.
Люди очень часто говорят, что TypeScript — это небольшая надстройка, superset над JavaScript, который добавляет типы. И что достаточно изучить несколько типов, чтобы начать писать на TypeScript и автоматически получать хороший код.
Действительно, можно просто писать типы в коде, объясняя компилятору, что в данном месте мы ожидаем переменную определенного типа. А компилятор скажет, можно так делать или нет:

Тем не менее не получится переложить всю работу на компилятор. Давайте посмотрим, какие типы надо изучить, чтобы понимать TypeScript.
-
Начнем с базовых типов, которые есть и в JavaScript: это boolean, number, string, symbol, bigint, undefined и object. Вместо типа function в TypeScript есть Function и отдельный синтаксис, подобный arrow function, но для определения типов. А тип object будет означать, что переменной можно присвоить любые объектные литералы в TypeScript.
-
Дальше есть примитивные, но уже специфичные для TypeScript типы: null, unknown, any, void, unique symbol, never, this.
-
Что еще? Named и object (не путать с object). Первый используется, когда мы пишем какое-то название интерфейса и после двух точек говорим, что у переменной тип Foo. У этого типа есть много разных названий, например, reference type, но мы остановимся на named. Тип object позволяет описать внутреннюю структуру объекта в виде специального синтаксиса. К сожалению, в терминологии TypeScript он называется точно так же, как и примитивный object.
-
Далее идут стандартные для многих объектно-ориентированных языков типы: array, tuple, generic.
Казалось бы, на этом можно остановиться, потому что если говорить про типовую систему той же Java, то больше ничего не нужно. Но TypeScript не останавливается: он предлагает union и intersection. В связке с этими типами часто работают и особые литеральные типы: string, number, boolean, template string. Они используются, когда функция принимает не просто строку, а конкретное литеральное значение, как “foo” или “bar”, и ничего другого. Это существенно повышает описательную способность кода.
Вроде бы уже достаточно, но нет! В TypeScript есть еще: typeof, keyof, indexed, conditional, mapped, import, await, const, predicate. И это лишь базовые типы, на их основе строятся многие другие. Например, композитный Record, который встроен в стандартную библиотеку. Или внутренние типы Uppercase и Lowercase, которые никак не определяются: это intrinsic типы.
Вроде бы уже достаточно сложно, чтобы не изучать TypeScript? Но трудности еще не закончились!
Выразительность типовой системы TypeScript
В 2017 году на GitHub появилась запись, что типовая система TypeScript является Turing Complete. То есть на типах TypeScript можно написать машину Тьюринга:

Задумайтесь — выразительная способность типовой системы TypeScript настолько высокая, что она Turing Complete и позволяет писать любые программы просто на типах! Но что с этим может быть не так? Давайте рассмотрим очень простую функцию changeCase, которая в зависимости от флага low делает строчке либо LowerCase(), либо UpperCase():
function changeCase(value, low) {
return low ?
value.toLowerCase() : value.toUpperCase();
}
Это довольно очевидный способ написать функцию как в JavaScript, так и в TypeScript. Но можно сделать и так:
declare function changeCase(value: T, low: Q):
Q extends true ?
Lowercase :
Q extends false ? Uppercase : string
changeCase("FOO", true); //type "foo"
changeCase("foo", false); //type "FOO"
Кажется, этот код невозможно прочесть, но идея в том, что когда мы передаем в нашу функцию значение true и какой-то строковый литерал, то на уровне типов мы получаем правильное итоговое значение. Задумайтесь! Мы не выполняем нашу функцию, но знаем, что она вернет для конкретной комбинации параметров (для флага true и для флага false).
Выразительность TypeScript позволяет делать просто умопомрачительные вещи. Вы можете не просто сказать, что функция вернет какое-то значение, а описать, что конкретно она будет возвращать даже для частных случаев.
Но есть нюанс. При попытке точно описать всё, что делает функция — мы легко можем попасть в ситуацию, когда в тайпингах какой-нибудь известной библиотеки (например, Styled Components) совершенно невозможно понять, что там происходит. Вот пример:

Здесь можно увидеть интерфейс ThemedStyledFunction, а в нем — набор generic параметров, которые выполняют совершенно непонятную функцию. Кроме того, интерфейс расширяет какой-то ThemedStyledFunctionBase.
Размотать эту цепочку и понять, что делает функция, практически невозможно без редактора, который хорошо поддерживает TypeScript. Кроме того, когда у нас не «срослись» типы, ситуация еще больше усугубляется. Для всего этого надо уметь ходить в декларации, по десяткам библиотек, которые друг друга наследуют и расширяют. В итоге мы уже не можем писать, как в старые добрые времена, на JS в каком-нибудь Sublime Text без языковой поддержки.
Конечно, мы сейчас говорим не про IDE, а про любой «умный» редактор, где есть языковой сервис. Например, это может быть Vim с поддержкой TypeScript Language Service.
Многие вещи всё ещё трудно выразить
Самое смешное, что несмотря на Turing-полноту, выразительность TypeScript все еще недостаточная, чтобы описать некоторые функции, которые есть в стандартной библиотеке JavaScript. Например, декларация Object.assign() выглядит в TypeScript 4.5 следующим образом:
assign(target: T, source: U): T & U;
assign(target: T, source1: U, source2: V): T & U & V;
assign(target: T, source1: U, source2: V, source3: W): T & U & V & W;
assign(target: object, ...sources: any[]): any;
Для двух, трех и даже четырех параметров мы еще возвращаем intersection, а для пяти уже сдаемся. В некоторых библиотеках можно увидеть до 90 таких сигнатур с разным количеством параметров. Здесь, как нельзя кстати подходит этот твит:

С типами мы пока закончили, переходим к другим сложностям.
Структурная типизация
Что такое структурная типизация? Это подход, при котором мы смотрим не на то, как называется тип или где он определяется, а на то, что он описывает внутри. Например, есть два интерфейса, которые определяют поле foo. Для TypeScript эти два интерфейса одинаковые, он не различает их в момент использования. Вы можете взять переменную одного интерфейса, присвоить в переменную другого, и всё будет работать:
interface Foo1 { foo: string }
interface Foo2 { foo: string }
let foo1: Foo1 = { foo: "text1" }; //ok
let foo2: Foo2 = { foo: "text2" }; //ok
foo1 = foo2; //ok
В TypeScript используется этот подход потому, что очень часто в JavaScript мы работаем с объектными литералами, которые не привязаны ни к какому типу. Довольно логично, что в таком случае, когда мы определяем просто объект с полем foo, то он может быть присвоен как первому интерфейсу, так и второму.
Перейдем к проблемам:
interface Foo { foo: string }
interface Bar { bar: string }
declare let foo: Foo;
declare let bar: Bar;
foo = bar;
Если присвоить две переменных из разных интерфейсов, то мы получим сообщение об ошибке:

Из-за структурной типизации мы уже не можем просто получить сообщение, что интерфейс Foo не совместим с интерфейсом Bar (или наоборот). Мы должны сказать, что одну переменную нельзя присвоить в другую, потому что в одном из интерфейсов не хватает какого-то поля, или в другом интерфейсе их слишком много. То есть нам нужно понимать внутреннюю структуру объекта и информировать о том, в каком именно месте типы не сошлись. Это легко, когда у нас вложенность первого уровня. Но если у нас вложенность на десятки, и тип не сошелся где-то очень глубоко, то выглядеть это может так:

Это реальное сообщение об ошибке, когда пользователь неправильно добавляет атрибут, при использовании Styled Components в TypeScript. Такое сообщение не только невозможно прочитать, но еще и не дает никакой информации о том, в чем именно проблема.
Структурная типизация для классов
Небольшой бонус: в TypeScript структурная типизация используется еще и для классов, и это просто магическая вещь. Например, у нас есть класс с полем foo:
class ClassFoo { foo?: number }
function test(p: ClassFoo) {
if (!(p instanceof ClassFoo)) {
//p is never here
console.log("hello never");
}
}
Как работает компилятор для этого кода? Внутри функции TypeScript знает, что переданный параметр p имеет тип ClassFoo. С другой стороны, внутри instanceof он не должен быть ClassFoo. То есть мы никогда не сможем попасть внутрь этого блока кода. Исходя из этого TypeScript считает, что тип переменной p внутри блока — это never. Но невозможное возможно!
class ClassFoo { foo?: number }
function test(p: ClassFoo) {
if (!(p instanceof ClassFoo)) {
//p is never here
console.log("hello never");
}
}
test({}); //prints “hello never”
За счет структурной типизации пустой объект все еще будет совместим на уровне типов с классом ClassFoo. Мы сможем передать его в эту функцию, где выводится сообщение «hello never» — чего, если верить типовой системе TypeScript никогда не должно случиться. Вот такая магия.
Анализ кода и Type Guard
Вы не обязаны проставлять типы повсеместно. Иногда TypeScript понимает сам, что в данном контексте после применения нескольких if-блоков у переменной будет правильный тип, и можно обращаться к свойствам этой переменной напрямую. Таких механизмов анализа в TypeScript довольно много, и это то, за что можно любить TypeScript. Подобные механизмы анализа есть и в Kotlin, а Java так не умеет.
Простой пример — есть код, мы его скомпилировали и получили ошибку:

Получили ошибку потому, что typeof null — это object. И компилятор TypeScript это знает.
Не очень опытные JS-разработчики могут не знать этого факта и допускать такие ошибки. А TypeScript знает и помогает написать более безопасный код. Посмотрим на другим примере, какие проблемы могут быть с таким анализом кода в TypeScript:

Какой тип у result: string или “bar” | “foo”? Видимо, string, раз в итоге ошибка компиляции. Но самое смешное — это то, как можно исправить эту проблему:
function rand(): "bar" | "foo" {
const result = Math.random() < 0.5
? "foo"
: "bar";
return result;
}
Просто написали const вместо let — и все скомпилировалось! Теперь по мнению компилятора, очевидно, что тип у result будет “bar” | “foo”.
Спецификация?
Вопреки ожиданиям, спецификация TypeScript не поможет разобраться в сложных алгоритмах вывода типов (с использованием Control Flow / Data Flow) — спецификации просто не существует уже много лет. До версии 1.8 она еще была, но после этой версии разработчики выпускают только handbook. Потому что считают, что спецификация никому не нужна, а работы для ее поддержания в актуальном состоянии требуется очень много. Даже сам файл спецификации из репозитория перенесли в архив, чтобы люди не пытались его редактировать.
Теперь давайте пройдемся по этим же пунктам снова и попробуем понять, так ли всё плохо и можно ли эти проблемы решить.
Реальность: так ли всё плохо на самом деле?
Начнем с того, что наличие сложных типов в языке не обязывает вас использовать их. Но есть очень важный нюанс — вы должны знать типы, потому что иначе вы не поймете код в тех же библиотеках. Для примера можно посмотреть, насколько часто используются сложные типы в исходном коде TypeScript и тайпингах React (react.d.ts):
Типы |
TypeScript |
react.d.ts |
Явно определяется тип, включая интерфейсы и все места, где после двоеточия стоит тип (включая вложенные) |
~ 67 000 мест |
~ 430 мест |
Используется тип Conditional |
23 места |
37 мест То есть в репозитории TypeScript, на 67 000 определений с типами их 23, а здесь из 430 мест — целых 37! |
Используется тип Mapped. |
5 мест |
1 место |
Хорошо иллюстрирует эту ситуацию твит от одного из создателей TypeScript:

Райан говорит, что проблема не с типовой системой TypeScript, а с экосистемой JavaScript: она настолько сложная, что ей требуются эти типы. А так — да, вас никто не заставляет их использовать при написании кода, при проектировании ваших API.
Тем не менее, типизация, если использовать ее аккуратно, делает любую функцию гораздо лучше. Например, наш замечательный пример с changeCase можно переписать следующим образом:
function changeCase
(value: string, low: boolean): string
Она уже не будет так эффектно выводить типы, но это будет читаемый код.
С другой стороны, высокая выразительность типовой системы TypeScript подводит нас к очень важной идее: типовая система — это язык программирования. И к его использованию применяются соответствующие требования, как к обычному коду, который мы пишем. То есть мы должны делать его понятным, не должны делать over-engineering и т.д.
Тогда другим вариантом типизации функции changeCase будет явное прописывание нескольких вариантов сигнатур. Это уже чуть лучше, хотя всё еще не идеально. Для случаев true и false просто определяются отдельные сигнатуры, плюс мы пишем общую сигнатуру, которая получится в итоге, если мы не знаем тип:
function changeCase
(value: TString, low: true): Uppercase
function changeCase
(value: TString, low: false): Lowercase
function changeCase
(value: string, low: boolean): string
Понятно, что это по-прежнему не очень читаемо, но уже гораздо лучше, чем двойной conditional тип, который был до этого.
TypeScript — это во многом про то, как надо и как не надо писать код, в также про соглашения и стиль написания кода. Конечно, многие вещи характерные для TypeScript можно реализовать на уровне каких-то конвенций: мы просто договариваемся внутри команды, что делаем явное приведение типов, не пишем какие-то конструкции и не присваиваем в переменную 10 разных типов, только чтобы ее переиспользовать, и т.д.
TypeScript позволяет это сделать более строго, и вам уже не нужно задумываться про такие мелочи. Вы будете знать, что на уровне TypeScript у вас уже есть некоторая строгость, которая определяет то, как ваша команда будет писать код.
Например, для JavaScript такой код будет абсолютно валидным:
console.log(Math.sin("3.1415"))
TypeScript скажет, что это неправильно:

Разработчики, которые только начинают писать код, могут быть не очень опытными и не совсем понимать, как писать правильно, а как писать нельзя. И TypeScript им сможет это подсказать.
Вернемся к случаю, когда тип переменной был не очень понятен. Использование const вместо let на самом деле — всего лишь трюк, о котором нужно знать. А правильное исправление — это добавлении типа:
function rand(): "bar" | "foo" {
let result: : "bar" | “foo" =
Math.random() < 0.5
? "foo"
: “bar";
return result;
Для TypeScript явное всегда лучше, чем неявное: когда мы говорим, что здесь используется конкретный тип, то мы сразу же убираем всю сложность, которую привносит неспецифицированный анализ типов в TypeScript.
На самом деле это касается и типов возвращаемых значений функций. Чтобы избавиться от всей магии, когда TypeScript начинает применять свои внутренние правила про то, как вывести и расширить тип литерала и т.д., можно просто указать явный тип. Читаемость кода значительно повысится за счет того, что люди будут знать, что эта функция возвращает.
Что касается редактора с языковой поддержкой, то он помогает решать огромное количество проблем, а сам процесс написания кода становится очень удобным. Потому что TypeScript даёт обратную связь в момент написания кода, а не в момент тестирования:

Конечно, сила TypeScript не только в этом, как мы уже увидели. Подсказки от редактора IDE могут значительно повысить продуктивность при написании кода. Да, мы чем-то пожертвовали: мы уже не можем писать код в блокноте. Но при этом мы выигрываем огромное количество времени просто за счет того, что редактор подсвечивает типы, говорит, что передавать в данную функцию и пр.
На заметку
О чем стоит помнить? Во-первых, что TypeScript — это индустриальный стандарт типизации. Текущее состояние JavaScript мира таково, что про типизацию — это TypeScript и ничто другое. Сейчас нет другого решения, которое бы позволило бы эффективно внедрить типизацию в проект. Можно, конечно, использовать какие-то контракты, конвенции или JSDoc для описания типов. Но все это будет гораздо хуже для читаемости кода по сравнению с типовыми аннотациями TypeScript. Они позволят не метаться вам глазами вверх-вниз, вы будете просто читать сигнатуру и тут же все понимать.
Второй момент — поддержка JavaScript в редакторах и IDE, как правило, базируется на TypeScript. Этот пункт очень нетривиален, но всегда забавно, когда говорят, что Visual Studio Code нормально писать на JavaScript и без TypeScript, что там и так всё работает. Потому что поддержка JavaScript во всех современных редакторах IDE строится на TypeScript! Поддержка JavaScript в VS Code реализована с помощью TypeScript Language Service. Поддержка JavaScript в WebStorm по большей части полагается на типовую систему TypeScript, и использует ее стандартную библиотеку.
Это наша реальность — вся поддержка JavaScript в редакторах строится поверх TypeScript. То есть нам в любом случае нужно изучать TypeScript. Потому что когда редактор говорит, что не сошлись типы в JavaScript, нам придется читать декларации из TypeScript.
Третий нюанс — Angular использует TypeScript как язык по умолчанию. Раньше у них на сайте можно было выбрать: «Покажи мне, как писать код на Angular в Dart (или в JS)». Но де-факто на Angular, кроме как c использованием TypeScript, никто не пишет. Если вы хоть раз пробовали писать на Angular без TypeScript — вы знаете, что это боль и страдание.
И наконец, TypeScript не заменяет другие инструменты повышения качества кода. TypeScript — это всего лишь один из инструментов, который позволяет вести какие-то конвенции в проекте и сделать так, чтобы были типы. Но вам все равно нужно писать тесты, делать код-ревью и уметь правильно проектировать архитектуру.
Выводы
-
TypeScript имеет много проблем, но, по мнению Андрея, плюсы перевешивают, причем значительно.
-
Вы не обязаны использовать TypeScript для каждого проекта. Если вы уже писали на TypeScript, то вы будете точно также хорошо писать код на JavaScript — у вас уже есть шаблон, как делать правильно, а как — нет. Это понимание приходит с опытом, после чего можно довольно гибко выбирать, где использовать TypeScript, а где нет.
-
Но если вы создаете внешнюю библиотеку, то у вас нет выбора: люди будут ее использовать в том числе с TypeScript. Для этой библиотеки должны быть типовые декларации. Единственный нормальный способ их получить — это написать библиотеку на TypeScript. Точно такая же ситуация, если вы делаете какой-то npm-пакет, которым будут пользоваться другие люди.
Профессиональная конференция фронтенд-разработчиков FrontendConf 2022 пройдет 7-8 ноябре в Сколково, Москва. Уже можно забронировать билеты и купить записи выступлений с прошедшей конференции FrontendConf 2021.
До 22 мая все еще открыт CFP, и, если вы хотите выступить, то подумайте об этом — Программный комитет ждет ваши заявки. Чтобы помочь вам развеять сомнения или уточнить тему для выступления — 28 апреля в 19:00 Программный комитет проводит онлайн-встречу. Регистрируйтесь и приходите, чтобы всё обсудить и понять, как лучше «упаковать» вашу тему!