Прогресивний TypeScript. Поступово і з мінімальними зусиллями
Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!
Тисячі років тому я писав статтю про цікаві, на мою думку, можливості TypeScript. Я хотів показати, що TS — це не просто JsDoc на стероїдах, а щось набагато більше. На жаль, поруч із можливостями, з’явилась і складність, місцями навіть надмірна. Тому сьогодні я хочу продемонструвати ще одну чудову якість цієї мови — її гнучкість, яка дозволяє вибудовувати систему саме такої суворості, яка потрібна у кожному конкретному випадку.
Стаття може бути цікава і початківцям, і тим, хто думає про міграцію своєї кодової бази з JS на TS.
Вихідні умови
План у мене простий. Беремо код на JS, адаптуємо його, щоб запустити на TS, потім поетапно покращуємо за допомогою можливостей TypeScript. Від вас — оцінити кількість зусиль та користь, яку ми за ці зусилля отримаємо. Єдине прохання — як будете оцінювати, вважайте, що над кодовою базою у нас працює хоча б 3 людини, а проєкт буде підтримуватися хоча б рік.
В якості прикладу я підготував наступну функцію (весь код доступний за посиланням):
async function fetchApi(url, options, mapper) { const fetchOptions = options ? { ...options } : {}; if (fetchOptions.body && typeof fetchOptions.body !== "string") { fetchOptions.body = JSON.stringify(fetchOptions.body); } if (!fetchOptions.headeres) { fetchOptions.headers = {}; } if (!fetchOptions.headers["Content-Type"]) { fetchOptions.headers["Content-Type"] = "application/json"; } const response = await fetch(url, fetchOptions).then((x) => x.json()); return typeof mapper === "function" ? mapper(response) : response; }
Сама функція — це просто обгортка над fetch
, яка трохи спрощує роботу з ним — заголовки за замовчуванням, серіалізація, десеріалізація, мапінг тощо. Я впевнений, ви не один раз писали щось на кшталт (той же axios, наприклад). Приклад трохи маленький і штучний (все як ми не любимо), але TS може працювати одночасно і з JavaScript, і з TypeScript, тому для ілюстрації самого підходу навіть такого шматочку має вистачити.
Тепер давайте подивимось, яку користь ми можемо отримати за допомогою TypeScript і скільки нам за це доведеться заплатити.
Talk is cheap
Отже, у нас є робочий код, давайте його перепишемо на TS. В першу чергу, давайте перейменуємо .js файл в .ts та спробуємо його виконати. Для таких штук я зазвичай використовую ts-node, він дозволяє виконувати ts файли, наче це звичайний JS. Ще можна взяти Deno, але зараз не про це.
Отже, запускаємо ts-node і ось перший результат, компілятор видає помилку:
Parameter 'url' implicitly has an 'any' type
Помилка типова і виникає тому, що TS підтримує підхід, що явне краще, ніж неявне, і за замовчуванням не дозволяє використовувати змінні, якщо він не розуміє їхній тип. Для того щоб цю помилку прибрати, ми можемо або вказати тип явно (навіть той самий any), або налаштувати TS під наші потреби. Саме для цього існує файл tsconfig.json — він відповідає за налаштування компілятора. Відкриваємо tsconfig (якщо у вас цього файлу немає, його можна створити за допомогою команди npx tsc --init
) і встановлюємо значення noImplicitAny
в false*
.
Ще раз намагаємося виконати наш код. Тепер все працює. Фактично, не змінюючи сам код, ми змогли запустити його за допомогою TS і тепер можемо помаленьку його покращувати (або погіршувати). Звичайно, у великому проєкті таке щастя навряд чи трапиться, але, по-перше, налаштувань в tsconfig багато і вони можуть дуже сильно спростити вам життя, а по-друге, ще раз нагадую, що TS може взагалі працювати з чистими JS-файлами. Тож ми можемо переводити проєкт пофайлово.
* "noImplicitAny": false
вважається не дуже гарною практикою і я б не радив зловживати ним. Зазвичай краще поставити any самостійно, аби бачити, де були втрачені типи.
Перші плоди
Отже, ми перетворили воду на вино, JS на TS, але наразі це призвело лише до погіршення ситуації. Ми додали складність в обмін на ніщо. Давайте це виправляти.
Почнемо з найпростішого: з першого аргументу метода fetch
, який має приймати URL нашого ресурсу.
Якщо ми будемо передавати туди просто якийсь текст, це може призвести до деяких проблем. По-перше, можна легко помилитися в написанні. По-друге, коли якийсь з ендпоінтів змінить свою адресу, потрібно буде бігати по всій кодовій базі та шукати всі місця, де цей ендпоінт використовується, і немає гарантії, що вас не відволічуть і ви не пропустите один з них. Тому, найчастіше, всі такі значення виносяться у константи — так їх і використовувати просто, і всі зміни зосереджені в одному місці.
Але і тут є маленький нюанс. Це потрібно пояснювати та й не все слідкують за стандартами (у світі рожевих поні — всі і завжди, але ми ще не там) і може виникнути така ситуація, коли хтось помилково знову буде використовувати просто string. Це спливе на CodeReview (або не спливе) і PR доведеться фіксити, що, очевидно, є витратою часу.
На щастя, цей маленький камінчик можна легко перекласти на плечі TypeScript. Для цього достатньо змінити об’єкт на enum та вказати цей enum в якості типу:
enum API { USER = `http://localhost:4000/user`, } async function fetchApi(url: API);
Тепер наш URL — це не просто довільний string
, а елемент enum
:
Якщо хтось випадково спробує використати звичайний string
— TypeScript про це попередить, а білд просто впаде під час PR-у, або під час пушу, якщо ви користуєтесь хуками git-а для валідації коду.
Типізація options
З першим аргументом розібралися, давайте спричинимо невідворотну шкоду і другому аргументу — об’єкту options.
Чи пам’ятаєте ви його структуру? Я, чесно кажучи, лише частково. Тому замість лізти в MDN, хотілося б мати працюючий Intellisense. На щастя, це не так і складно, оскільки ми можемо використовувати типи, які вже написані за нас, а саме тип RequestInit
:
async function fetchApi(url: API, options: RequestInit);
Але з цим є дві проблеми.
По-перше, як з’ясувалося, я допустив помилку в перевірці: if (!options.headeres)
. В результаті цієї помилки всі заголовки, які ми передавали у функцію, видалялися і встановлювалися у дефолтні значення. Це досить неприємна помилка, яка може існувати в коді довгий час непоміченою (до першого кастомного хедера + дебаг). По-друге, в RequestInit
тип body не може бути довільним об’єктом.
Першу проблему вирішити нескладно, достатньо просто виправити помилку. З другою проблемою трохи складніше.
Найпростішим рішенням (і найгіршим) буде щоразу, під час виклику fetchApi
, кастити body до any, наприклад, ось так: body: { id: 5 } as any
. Це спрацює, але навіщо себе повторювати і захаращувати код зайвим? Тому є інший варіант — змінити тип RequestInit на щось краще. Наприклад, ми можемо створити свій власний тип для того, щоб спростити тип RequestInit
, — прибрати зайві HTTTP дієслова, заборонити кастомні хедери, абощо. Але зараз писати свій тип — трохи суперечить ідеї статті. На щастя, є золота середина — створення власного типу на основі вже існуючого.
// наслідуємо IAppRequestInit від RequestInit interface IAppRequestInit extends RequestInit { // перевизначаємо body** body: any; } async function fetchApi(url, options?: IAppRequestInit, mapper?) { // вказуємо IAppRequestInit як тип для const fetchOptions = options ? { ...options } : ({} as IAppRequestInit); }
* За допомогою .d.ts
файлів також доступна модифікація глобальних типів. Але наврядчи ви захочете глобально змінювати RequestInit по всій апці.
** body:any
не найкращий вибір, оскільки не дозволяє типізувати пейлоад. Краще використовувати узагальнений інтерфейс.
Що у відповіді
Тепер до більш цікавого. Що повертає нам fetchApi
? Треба дивитися в документацію або свагер, якщо такі є. А якщо документації немає, або вона оновлювалася ща за царя Панька, то виникають проблеми. Більше того, навіть якщо документація є, інтелісенсу це зовсім не допомагає. Давайте це виправимо.
Крок перший. Узагальнюємо метод fetchApi
і вказуємо тип значення, що повертається.
async function fetchApi<T>( url: API, options: IAppRequestInit, mapper ): Promise<T>;
Крок другий. Під час виклику функції вказуємо тип даних, на які очікуємо.
const user = await fetchApi<{ name: string }>(API.USER, { id: 3 });
Все, цього вже досить для того, щоб і IntelliSense зрозумів тип об’єкту і TypeScript працював коректно.
Було:
Стало:
Але з цим підходом є дуже велике але (не робіть так).
В замовлення поклали не те
Приклад вище — чудова демонстрація, як за допомогою TS тихенько вистрілити собі коліно. Давайте поглянемо на наступний приклад (зверніть увагу на третій аргумент функції):
const user = await fetchApi<{ name: string }>(API.USER, { id: 3 }, () => null); console.log(user.name);
З точки зори системи типів, все ОК, компілятор код пропускає. Але в runtime код очевидно впаде з помилкою Cannot read properties of null (reading 'name')
. І от якраз тут і можна було б звинувачувати TS у непотрібності, але є одне але — провина тут лежить повністю на мені, тому що замість того, щоб дати TS можливість самому вивести тип — я фактично змусив його використати мій.
Та виправити це не складно. Якщо тип значення, що повертається з fetchApi
залежить від типу значення, яке повертає mapper, то все, що нам потрібно, просто пов’язати ці типи. Для цього ми просто вказуємо, що mapper повертає той самий узагальнений тип T, що й fetchApi
:
async function fetchApi<T>( url: API, options: IAppRequestInit, mapper?: (x: unknown) => T ): Promise<T>;
Тепер TypeScript самостійно виведе тип значення, що повертається, базуючись на функції мапінгу і нам навіть не доведеться цей тип вказувати явно:
const user = await fetchApi(API.USER, { id: 3 }, () => null); // Object is possibly 'null'.ts(2531) console.log(user.name);
Підсумуємо
Отже, що ми зробили:
- Перевели «проєкт» з JS на TS, не змінюючи сам код, лише за допомогою налаштувань TS.
- Типізували URL — тепер що завгодно туди не передаси, хардкодити ендпоінти також не можна.
- Додали Intellisense для об’єкта options.
- Додали розумну типізацію для повертаємого з
fetchApi
значення.
При цьому всі зміни атомарні, ви можете використати всі, можете вибрати один будь-який. І саме в цьому полягає та ідея, яку я хотів продемонструвати в цій статті. TypeScript — потужний і гнучкий інструмент, який повністю підлаштовується під ваші вимоги. Хочете лайтову версію — вимикаємо всі strict правила, дозволяємо JS і вперед. Хочеться хардкору — викручуємо все на максимум і малюємо діаграми з типами цілий день. Все залежить від вас та від ваших вимог.
І наостанок маленька gif-ка того, що ми отримали:
Післямова: згідно з останніми опитуваннями на StackOverflow, TypeScript — п’ята за популярністю мова.
Приклади коду за посиланням
33 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів