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<T>, который встроен в стандартную библиотеку. Или внутренние типы Uppercase<T> и Lowercase<T>, которые никак не определяются: это 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<T extends string,
Q extends boolean>(value: T, low: Q):
Q extends true ?
Lowercase<T> :
Q extends false ? Uppercase<T> : 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<T, U>(target: T, source: U): T & U;
assign<T, U, V>(target: T, source1: U, source2: V): T & U & V;
assign<T, U, V, W>(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<TString extends string>
(value: TString, low: true): Uppercase<TString>
function changeCase<TString extends string>
(value: TString, low: false): Lowercase<TString>
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 Программный комитет проводит онлайн-встречу. Регистрируйтесь и приходите, чтобы всё обсудить и понять, как лучше «упаковать» вашу тему!