Pull to refresh
545.27
Яндекс
Как мы делаем Яндекс

Делаем TypeScript строже. Доклад Яндекса

Reading time 10 min
Views 11K
Как сделать из TypeScript строгого, но справедливого товарища, который защитит тебя от неприятных ошибок и придаст больше уверенности в коде? Алексей Веселовский veselovskiyai рассмотрел несколько особенностей конфигурации TS, которые закрывают глаза на непростительные вольности. В докладе рассказывается о тех вещах, которых лучше избегать, и о тех, с которыми нужно быть предельно осторожным. Вы узнаете о замечательной библиотеке io-ts — она позволяет без труда обнаружить и даже пресечь попадание в код данных, которые могут вызвать ошибки в идеально написанных местах.

— Всем привет, меня зовут Лёша, я разработчик фронтенда. Начнем. Я немножко расскажу о себе и о проекте, в котором работаю. Флоу — это изучение английского от Яндекс.Практикума. Релиз состоялся в апреле этого года. Фронт был написан сразу на TypeScript, до этого никакого кода не было.



Немножко про мой опыт. В каком-то далеком году я начал заниматься программированием. Году в 2013-м начал работать.



Почти сразу понял, что мне гораздо интереснее фронт, но у меня опыт был с языками со статической типизацией. Я начал использовать JavaScript, и там не было этой статической типизации. Мне это показалось удобным, понравилось.

При смене проекта я попал на работу, где использовался TypeScript. Расскажу о плюсах, которые я понял, перейдя на TypeScript. Проще разбираться в проекте. У нас есть описание типов данных, которые используются в проекте и преобразования между ними.



Безопаснее вносить изменения в код: при каких-то изменениях бэкенда или просто какой-то части кода TypeScript нам подсветит места, в которых появились ошибки.

Становится меньше беспокойства о типах. Когда мы делаем новую функциональность, то сразу задаем типы, с которыми работают функции, и можем меньше беспокоиться, что у нам придут другие данные.

Нет страха, что придет null или undefined, нам не нужно параноить, вставлять ненужные if и подобные конструкции.



В начале этого года я перешел во Флоу. Тут тоже используется TypeScript, но я его немножко не узнал. Почему? Он был слишком добр ко мне, четверть клиентских ошибок были связаны с null и undefined. Я начал разбираться, в чем дело, и нашел одну строчку в конфигурации, которая меняла все поведение TypeScript



Это включение strict. Его не было, но требовалось включить, чтобы улучшить проверку.

TypeScript: strict


Что из себя представляет strict? Из чего он состоит?



Это набор флагов, которые можно включить по отдельности, но на мой взгляд, они все очень полезны. noImplicitAny — до включения этого флага мы можем объявлять, например, функции, параметры которых будут неявными, типа any. Если мы включаем этот флаг, то обязаны добавить типизацию в тех местах, где TypeScript не может вычислить тип исходя из контекста.

То есть во втором случае мы обязаны добавить типизацию, так как контекста как такового нет. В третьем случае, где у нас map, мы можем не добавлять типизацию для a, потому что из контекста ясно, что будет тип number.



noImplicitThis. TypeScript нас обязывает типизировать this, когда нет контекста. Когда контекст есть, то есть это объект или класс, нам этого делать не обязательно.



alwaysStrict. Добавляет “use strict” в каждый файл. Но это влияет на то, как JavaScript исполняет наш код. (...)



strictBindCallApply. Почему-то до включения этой опции TypeScript не проверяет bind, apply и call на типы. После включения он их проверяет и не дает нам совершить вот такие гадкие вещи.



strictNullChecks — это, на мой взгляд, самая нужная проверка. Она обязывает нас указывать в типизации места, где может прийти null или undefined. До включения мы можем передать null или undefined там, где это явно не указано, и, соответственно, получить ошибку. После этого контроль будет гораздо лучше.



Дальше, strictFunctionTypes. Здесь ситуация немножко сложнее. Представим, что у нас есть три функции. Одна работает с животными, другая с собаками, третья с кошками. Собака и кошка — это животные. То есть ошибочным будет работать с собакой так же, как с кошкой, потому что они разные. Правильно будет работать с собакой так же, как с животным.

Третий вариант — когда мы пытаемся работать с любым животным, как с собакой. Почему-то изначально он допустим у TypeScript, но если включить эту опцию, он будет недопустим и будут производиться определенные проверки.



Дальше, strictPropertyInitialization. Это для классов. Он обязывает нас задавать начальные значения или при объявлении свойства, или в конструкторе. Иногда бывают случаи, когда нужно обойти это правило. Можно использовать восклицательный знак, но, опять же, это обязывает нас быть немножко аккуратнее.

Итак, я выяснил, что нам надо включить strict. Пытаюсь включить, и выскакивает очень много ошибок. Поэтому было принято решение использовать переходную конфигурацию к strict. Мы устанавливаем strict в три этапа.



Первый этап: мы в tsconfig добавляем “strict”: true, и, соответственно, нам наша среда разработки подсказывает места с ошибкой, которая вызвана именно включением strict.

Но для webpack создаем специальный tsconfig, у которого strict будет false, и используем это при сборке. То есть при сборке у нас ничего не ломается, но в нашем редакторе мы видим эти ошибки. И сразу можем их исправить. Дальше мы время от времени обращаемся ко второму этапу, это исправление. Мы собираем наш проект с обычным tsconfig. Правим часть ошибок, которые вылезли, и повторяем все это в свободное время.

Такими действиями мы пока уменьшили количество наших ошибок с 400 до 200. Мы очень ждем перехода на третий этап — к удалению webpackTsConfig и использованию tsconfig при сборке, но уже с включенным strict.

TypeScript: небольшие тонкости


Можно немного поговорить о небольших тонкостях TypeScript, которые не покрываются strict, но их сложно правильно формализовать.



Начнем с оператора «восклицательный знак». Что он позволяет делать? В данном случае — обратиться к полю, которое может быть undefined, так, будто оно не может быть undefined. Оно имеет смысл в strict-режиме, когда мы пытаемся обратиться к полю, явно говоря: я уверен, что оно точно не null и не undefined. Но это плохо, потому что если оно вдруг оказывается null или undefined, то мы, естественно, получаем ошибку во время выполнения.

Избежать таких вещей нам поможет ESLint, он просто будет нам это запрещать. Мы это сделали. Как теперь исправить предыдущий пример?

Предположим, у нас вот такая ситуация.



Есть элемент, он может быть типа link или span. Головой мы понимаем, что span — это только текст, а link — это текст и ссылка.

(картинка)

Но языку TypeScript мы об этом забыли сказать, поэтому в функции getItemHtml возникает ситуация, что в случае с link нам приходится говорить: href не опциональный, он обязательно будет. Это тоже потенциальное место для ошибки. Как это исправить?



Первый вариант — исправить типизацию, то есть явно указать для TypeScript, что для link обязателен href, а для span не обязателен.



И восклицательный знак здесь будет не нужен.



Второй вариант исправления. Допустим, тип Item описан не у нас и мы не можем просто так взять и ограничить его. Тогда мы можем переписать это подобным образом.



Обратите внимание: просто появилась проверка. Дальше идет логирование о том, что программист во время написания этого кода не ожидал это значение, поэтому в будущем мы увидим эту ошибку и примем соответствующие меры.

Дальше мы пытаемся хоть как-то отрендерить наш Item. Здесь можно просто выдать пользователю ошибку. Но если это какие-то незначительные данные, то можно сделать заглушку, как здесь.

as



Дальше. Есть также оператор as. Что он позволяет сделать?



Он позволяет сказать — я лучше знаю, здесь такой-то тип — и тоже привести себя к ошибке.

Массивы


Методы борьбы такие же. С чем нужно быть немножко аккуратнее, так это с массивами. TypeScript — не панацея, он не проверит некоторые моменты. Например, мы можем обратиться к несуществующему элементу массива. В данном случае мы возьмем первый элемент массива и получим именно в этом коде ошибку. Как мы можем это исправить?



Снова есть два способа. Первый способ — типизация. Мы говорим, что у нас обязательно есть первый элемент, и безбоязненно обращаемся к этому элементу. Или проверим, залогируем, если вдруг что-то не так, если мы явно ожидаем непустой массив.

Объекты


То же самое с объектами. Мы можем объявить объект, у которого может быть любое количество свойств, и тоже получить ошибку с undefined.



Вновь можно сделать явные указания, какие свойства обязательны, или просто проверять.

any


Теперь явная вещь — any.



Она позволяет обратиться к любому свойству объекта, как будто типизации нет вообще. В данном случае мы можем делать с x все, что захотим. И опять выстрелить себе в ногу, получить ошибки.

Это вновь лучше явно запретить с помощью ESLint. Но есть ситуации, когда это появляется само.



Например, в данном случае JSON.parse выдает как раз этот тип any. Что можно сделать?



Можно просто сказать: я не верю тебе, давай лучше я скажу, что не знаю, что это такое, и буду с этим жить дальше. Как с этим жить? Вот гипотетический пример.



Есть пользователь, у пользователя обязательное имя и опциональный e-mail.



Мы пишем функцию parseUser. Она принимает JSON-строчку и возвращает нам наш объект. Теперь начинаем все это проверять. Сначала видим знакомую нам с предыдущего слайда строчку с parse и unknown. Далее начинаем проверки.



Если это не является объектом или является null, выдаем ошибку.



Дальше, если нет обязательного свойства name или оно не является строкой, выдаем ошибку. Вот продолжение кода.



Мы начинаем формировать User, так как все обязательные поля уже собраны.



Дальше мы проверяем, есть ли поле email. Если оно есть, то мы проверяем его тип и, если тип не совпадает, выдаем ошибку. Если нет email, то ничего не присылаем и возвращаем результат. Всё, отлично. Но нужно писать достаточно много для простейшего типа.



А нужно много проверок


Нам нужно много проверок, потому что типичный JSON-запрос выглядит так.



Без лишних подробностей, это просто fetch и json(). В return появляется преобразование из any в SomeRequestResponse. С этим тоже необходимо бороться. Можно предыдущим способом, а можно немного другим.

io-ts


Под капотом то же самое: используем специальную библиотеку для проверки типов. В данном случае это io-ts. Вот простой пример, как с ней работать.



Возьмем предыдущий тип user и напишем его в рамках библиотеки, которую мы используем. Да, типизация здесь получается немножко сложнее, но одновременно должны выполняться два условия. Это должен быть объект с обязательным полем name и объект с необязательным полем email. Как мы всё это можем проверить?



Напишем тот же самый parseUser. В данном случае мы используем метод User.decode. Передаем туда уже спаршенный объект, он нам возвращает результат. Возможно — в непривычном формате. Объект типа Either, он может быть в двух состояниях. Первое — right. Это обычно говорит о том, что все прошло хорошо. left говорит, что все прошло не очень. В обоих этих состояниях есть свойства, которые позволяют нам узнать больше. В случае успеха это результат выполнения, в случае ошибки — ошибка.

Мы проверяем, находятся ли наши результаты в состоянии left. Если находятся — мы говорим, что произошла ошибка. Дальше, если все хорошо, просто возвращаем результат.

Отображение ошибок




Про отображение ошибок. Можно его немножко улучшить. Для этого воспользуемся io-ts-reporters. Это библиотека, которая написана тем же автором, что и io-ts. Она позволяет красиво представить ошибку. Что она делает? Мы изменили код здесь, где утка. Она принимает результат и возвращает массив строк. Мы его джойним просто в одну строчку и выводим. Что мы получаем в итоге?



Предположим, мы передаем null в JSON-строчку.



Он выдаст две ошибки. Это связано с тонкостью реализации, с тем, что мы сделали intersection. Ошибки достаточно понятные. Обе из них говорит, что мы ожидали объект, а получили null. Просто на каждое из этих условий он выдаст ошибку отдельно.



Дальше попробуем передать туда пустой массив. Будет то же самое.



Он нам просто скажет: я тоже ожидал объект, но получил пустой массив.



Итак, продолжаем смотреть, что будет, если мы начнем передавать некорректные данные. Например, передадим пустой объект.



Теперь он выдаст одну ошибку про то, что у нас нет обязательного поля name. Он ожидал, что поле name будет типа string, а получает в итоге undefined. Из этой ошибки тоже легко понять, что произошло.



Дальше мы попробуем передать туда некорректный тип. Тоже получаем ошибку, примерно такую же, как в предыдущем примере.



Но здесь он нам явно пишет значение, которое мы передали.



Что еще позволяет делать io-ts? Он позволяет получить TypeScript-тип. То есть мы добавляем вот эту строчку. Просто добавив typeof, еще typeof, мы получаем тип TypeScript, который можем дальше использовать в приложении. Удобно.



Что еще может эта библиотека? Преобразовывать типы. Допустим, мы делаем запрос на сервер. Сервер отдает даты в формате unix time. И есть специальная библиотека, опять же от создателя библиотеки io-ts: io-ts-types. Там есть такие преобразования, которые изначально написаны, и инструменты, чтобы эти преобразования было проще написать. Мы добавляем поле date: с сервера оно приходит как число, а мы в итоге получаем его как объект типа Date.

Опишем тип


Посмотрим, что внутри этой библиотеки, и попробуем описать простейший тип.



Для начала посмотрим, как он вообще описан. Он описан так же, достаточно сложно, с учетом того, что это нужно еще и для преобразований. Как в сторону от сервера к клиенту, если мы рассматриваем взаимодействие с сервером, так и обратное преобразование, от клиента к серверу.

Немножко упростим себе задачу. Мы просто напишем тип, который проверяет. В данном случае — разберемся, что обозначают эти поля. name — название типа.



Оно требуется, чтобы отображать ошибки. Как мы видели в предыдущих примерах, в ошибках каким-то образом пишется название типа. Здесь его можно указать.

Дальше, есть функция validate. Она принимает — допустим, с сервера — значение unknown; принимает контекст, чтобы правильно отобразить ошибку; и возвращает объект Either двумя состояниями — или ошибка, или валидированное значение.

Есть еще две функции: is и encode. Они используются для обратного преобразования, но пока давайте не будем их трогать.



Как можно представить простейший тип string? Мы задаем название string и проверку, что это является строкой. При прямом преобразовании это не будет нужно, но формально мы это пишем. И дальше для проверки мы просто делаем typeof. В случае успеха возвращаем результат success, а в результате ошибки failure. Еще добавляется контекст, чтобы корректно отобразилась ошибка. И мы просто возвращаем то же самое, потому что обратного преобразования нет.

На практике


Что на практике? Почему мы вообще решили проверять данные, которые приходят с сервера?



Как минимум, в базе данных есть JSON. Мы, конечно, верим, что его хорошо заведут и что он проверится в каких-то моментах. Но формат может немного измениться, мы должны не ломать фронтенд или сразу узнавать об ошибках, чтобы предпринять ответные действия.

У нас на сервере Python без явной типизации. С этим тоже иногда могут быть небольшие проблемы. И чтобы нам не ломаться, мы можем просто проверить и дополнительно себя обезопасить на всякий случай.

Нет четкой документации по ответам сервера. Наверное, сервер больше беспокоится о том, что к нему придет, нежели о том, что он отдаст. Да, это больше наша проблема — не сломаться.



Что мы находили? Мы уже начали понемногу это использовать. Находили, что сервер отдает нам пустой объект вместо пустого массива. Я просто посмотрел по коду — написан возврат пустого объекта.

Дальше — отсутствие некоторых полей. Мы считали их обязательными, а они оказываются не обязательными.

Поле, допускающее null, в каких-то случаях просто отсутствовало. То есть необязательное поле можно представить в двух вариантах: либо когда мы просто не передаем его, либо когда передаем null. Оно тоже не всегда корректно к нам приходило. Чтобы не ловить ошибки посередине нашего кода, мы можем это отлавливать как раз при запросах.



Что у нас есть сейчас? Мы проверяем уже достаточно много ответов от сервера и логируем, если нам что-то не нравится. Потом анализируем это и ставим таски: либо на изменение типизации у нас на фронтенде, либо правки на бэкенде. Сейчас мы не меняем данные, которые приходят с сервера: если пришел null вместо строки, мы не меняем его, например, на пустую строку.

Наши планы — проверять и логировать, но исправлять при ошибке. Если к нам приходят некорректные данные, мы исправим это значение, чтобы пользователи могли отобразить хоть что-нибудь вместо падения внутри нашего кода.



Небольшие итоги. Мы включаем strict, чтобы TypeScript больше нам помогал, исключаем as, any и восклицательный знак. Будем аккуратнее работать с массивами и объектами в TypeScript, а также проверяем все внешние данные. Это, кстати, не только серверы. Можно так же проверять localStorage, сообщения, которые приходят в событиях. Например, postMessage.

Спасибо за внимание.
Tags:
Hubs:
+16
Comments 14
Comments Comments 14

Articles

Information

Website
www.ya.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия
Representative