На конференции 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 Программный комитет проводит онлайн-встречу. Регистрируйтесь и приходите, чтобы всё обсудить и понять, как лучше «упаковать» вашу тему!