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

Как сделать из TypeScript строгого, но справедливого товарища, который защитит тебя от неприятных ошибок и придаст больше уверенности в коде? Алексей Веселовский veselovskiyai рассмотрел несколько особенностей конфигурации TS, которые закрывают глаза на непростительные вольности. В докладе рассказывается о тех вещах, которых лучше избегать, и о тех, с которыми нужно быть предельно осторожным. Вы узнаете о замечательной библиотеке io-ts — она позволяет без труда обнаружить и даже пресечь попадание в код данных, которые могут вызвать ошибки в идеально написанных местах.— Всем привет, меня зовут Лёша, я разработчик фронтенда. Начнем. Я немножко расскажу о себе и о проекте, в котором работаю. Флоу — это изучение английского от Яндекс.Практикума. Релиз состоялся в апреле этого года. Фронт был написан сразу на TypeScript, до этого никакого кода не было.
m7jbw5qdxu5ywgwyldujtnkwz3w.jpeg

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

-aish781yarpmrjcryarxejqacc.jpeg

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

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

cbdwebcteq0vwuikc83djm985os.jpeg

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

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

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

pesfjybwyqzxjqjgxjgbhhrvii4.jpeg

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

3jiqe7exa8q3u7sgv9po92ldmpy.jpeg

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

TypeScript: strict


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

ipiy7g8csoeahj7j6sxcdwhxb6c.jpeg

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

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

m5m56komv4zusofwstpoa_bem_4.jpeg

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

chpwq246n9gljpn3cn_5_fxj4ze.jpeg

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

-dbdglxxebglvd5e1oes73zx9ac.jpeg

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

t0bznhedcwtjujzncg_nmfng8oo.jpeg

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

5_vvjotipk6kkqlpb54_hz6afwu.jpeg

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

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

ddq3f50drqp26ebp9_erfjwarke.jpeg

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

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

zpfykpyau0oi3zrnkknvyt4d7ma.jpeg

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

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

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

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


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

zikaffboma0fc3e4zo9ojrek-jk.jpeg

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

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

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

sdpjs3z-wrzkt4iczbgez6limlg.jpeg

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

(картинка)

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

o5kdkq8xislvmgqquqrh-exyjcw.jpeg

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

yxunbwfvoifb7s_kvgcqns5u7ls.jpeg

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

zq4-p2amvvpdthouyyegne7lioo.jpeg

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

wwywqhgfxvty1kpzwt69mcvbhxq.jpeg

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

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

as

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

-0xjoz59omrlbjp_jojmcb7yz_c.jpeg

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

Массивы


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

5hmik9qimldxi0fb0yykwry2_oc.jpeg

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

Объекты


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

wg8gf8zboucfw5f0vnhkdf6m7cy.jpeg

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

any


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

rcnyx3s37yu6_xbhidk9nlxsq20.jpeg

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

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

y6grc-ke5yfyawug1jnhf31n7tu.jpeg

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

s-mxnu8ht9af8kiiqilisfn2ufq.jpeg

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

6rhhkdghdcrovngb9fnfsutw8fc.jpeg

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

z5gqojvebleigh7od4qr_xomyzq.jpeg

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

nt_z3lu_iylolb3gnn0o_v9zeyg.jpeg

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

6tqrevmeinfjft7epcgvomfhsbi.jpeg

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

k1nn7ualrlse0hkc07ayqgzsuzg.jpeg

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

htwuf_3nwtmuaaweodtpsflrhno.jpeg

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

am2cid1xdeev_k7h1cty_drsgoc.jpeg

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


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

p9i8gqv_4faq6oz2wngu7ozj-f0.jpeg

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

io-ts


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

pj-qzmmswtftsj3xstzzqtke61m.jpeg

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

duuz6ci6vgqsoxh6pzuzroorc60.jpeg

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

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

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


tdhj_tm6q4mkmjqvlsgbsaxq5y0.jpeg

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

st4h6smpf4pfy9067mddldjrd5q.jpeg

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

_fy-i4whbaidce9rucnff_idi-s.jpeg

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

pkkzkprd4ltautifucvtvtkmbla.jpeg

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

uyfjhmdbwtokekjvv3ccfkvopzc.jpeg

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

tnmspaq2om8faiazr1hromtt4mo.jpeg

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

bzbx4traaskgvakejke8lb9owuw.jpeg

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

ubwlacj96idkomieznxucj2ay6q.jpeg

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

duyax8skjesaprohgsddj16grck.jpeg

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

nbmcabk_nrubmnmzcxgxkrvhvf4.jpeg

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

phdm2gnlkhg-1eddnwo8_rvmuac.jpeg

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

Опишем тип


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

kh4ck5h2eecainj6n3xki4qckoy.jpeg

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

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

psrlstliwgnxpzgjq5fhmwycisy.jpeg

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

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

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

mcf1smqoqkqu8o60tzk669pdgim.jpeg

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

На практике


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

0dif2jrrjtrg7anh44dhayzil4o.jpeg

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

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

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

pyy-zz-cl8k15cfiulujn5_b_0i.jpeg

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

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

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

07dps0df2nnbmty5jpg4xh7xnwg.jpeg

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

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

wu1e-ecfu8uvyzhfpy5gn6747m8.jpeg

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

Спасибо за внимание.

© Habrahabr.ru