Дізнайтеся, як досягти безпечного за типами, верифікованого під час компіляції зіставлення зі зразком у JavaScript за допомогою TypeScript, розрізнених об'єднань і сучасних бібліотек для написання надійного, безпомилкового коду.
Зіставлення зі зразком і безпека типів у JavaScript: Посібник з верифікації під час компіляції
Зіставлення зі зразком — це одна з найпотужніших і виразних функцій у сучасному програмуванні, яка здавна цінується у функціональних мовах, таких як Haskell, Rust і F#. Вона дозволяє розробникам деконструювати дані та виконувати код на основі їхньої структури у спосіб, який є одночасно лаконічним і неймовірно читабельним. Оскільки JavaScript продовжує розвиватися, розробники все частіше прагнуть перейняти ці потужні парадигми. Однак залишається значна проблема: як нам досягти надійної безпеки типів і гарантій часу компіляції цих мов у динамічному світі JavaScript?
Відповідь полягає у використанні статичної системи типів TypeScript. Хоча сам JavaScript наближається до нативного зіставлення зі зразком, його динамічна природа означає, що будь-які перевірки відбуватимуться під час виконання, що потенційно може призвести до несподіваних помилок у виробництві. Ця стаття є глибоким зануренням у техніки та інструменти, які забезпечують справжню перевірку зразка під час компіляції, гарантуючи, що ви виявляєте помилки не тоді, коли їх виявляють ваші користувачі, а коли ви набираєте код.
Ми дослідимо, як будувати надійні, самодокументовані та стійкі до помилок системи, поєднуючи потужні функції TypeScript з елегантністю зіставлення зі зразком. Будьте готові усунути цілий клас помилок під час виконання та писати код, який є безпечнішим і простішим в обслуговуванні.
Що таке зіставлення зі зразком?
По суті, зіставлення зі зразком — це складний механізм керування потоком. Це як надпотужний оператор `switch`. Замість просто перевіряти рівність простим значенням (таким як числа або рядки), зіставлення зі зразком дозволяє перевіряти значення на відповідність складним «зразкам» і, якщо збіг знайдено, прив’язувати змінні до частин цього значення.
Давайте порівняємо це з традиційними підходами:
Старий спосіб: ланцюжки `if-else` і `switch`
Розглянемо функцію, яка обчислює площу геометричної фігури. За традиційного підходу ваш код може виглядати так:
// Shape is an object with a 'type' property
function calculateArea(shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius * shape.radius;
} else if (shape.type === 'square') {
return shape.sideLength * shape.sideLength;
} else if (shape.type === 'rectangle') {
return shape.width * shape.height;
} else {
throw new Error('Unsupported shape type');
}
}
Це працює, але це багатослівно та схильне до помилок. Що, якщо ви додасте нову фігуру, наприклад, `triangle`, але забудете оновити цю функцію? Код видасть загальну помилку під час виконання, яка може бути далеко від того місця, де було внесено фактичну помилку.
Спосіб зіставлення зі зразком: декларативний і виразний
Зіставлення зі зразком перефразовує цю логіку, щоб зробити її більш декларативною. Замість серії імперативних перевірок ви оголошуєте зразки, які очікуєте, і дії, які потрібно виконати:
// Pseudocode for a future JavaScript pattern matching feature
function calculateArea(shape) {
match (shape) {
when ({ type: 'circle', radius }): return Math.PI * radius * radius;
when ({ type: 'square', sideLength }): return sideLength * sideLength;
when ({ type: 'rectangle', width, height }): return width * height;
default: throw new Error('Unsupported shape type');
}
}
Основні переваги стають одразу очевидними:
- Деструктуризація: Значення, такі як `radius`, `width` і `height`, автоматично витягуються з об’єкта `shape`.
- Читабельність: Намір коду зрозуміліший. Кожен пункт `when` описує певну структуру даних і відповідну логіку.
- Вичерпність: Це найважливіша перевага для безпеки типів. Дійсно надійна система зіставлення зі зразком може попередити вас під час компіляції, якщо ви забули обробити можливий випадок. Це наша основна мета.
Проблема JavaScript: Динамізм проти безпеки
Найбільша сила JavaScript — її гнучкість і динамічна природа — також є її найбільшою слабкістю, коли йдеться про безпеку типів. Без статичної системи типів, яка забезпечує контракти під час компіляції, зіставлення зі зразком у звичайному JavaScript обмежується перевірками під час виконання. Це означає:
- Відсутність гарантій під час компіляції: Ви не дізнаєтесь, що пропустили випадок, поки ваш код не запуститься та не досягне цього конкретного шляху.
- Мовчазні збої: Якщо ви забудете випадок за замовчуванням, значення, яке не відповідає, може просто призвести до `undefined`, що спричинить незначні помилки нижче за течією.
- Кошмари рефакторингу: Додавання нового варіанту до структури даних (наприклад, нового типу події, нового статусу відповіді API) вимагає глобального пошуку та заміни, щоб знайти всі місця, де його потрібно обробити. Пропуск одного може зламати вашу програму.
Саме тут TypeScript повністю змінює гру. Його статична система типів дозволяє нам точно моделювати наші дані, а потім використовувати компілятор, щоб забезпечити обробку кожної можливої варіації. Давайте дослідимо, як.
Техніка 1: Основа з розрізненими об'єднаннями
Найважливішою функцією TypeScript для забезпечення безпечного для типів зіставлення зі зразком є розрізнене об'єднання (також відоме як позначене об'єднання або алгебраїчний тип даних). Це потужний спосіб моделювання типу, який може бути одним із кількох різних можливостей.
Що таке розрізнене об'єднання?
Розрізнене об'єднання складається з трьох компонентів:
- Набір різних типів (елементи об'єднання).
- Спільна властивість із літеральним типом, відома як дискримінант або тег. Ця властивість дозволяє TypeScript звузити конкретний тип в об'єднанні.
- Тип об'єднання, який об'єднує всі типи елементів.
Давайте переробимо наш приклад форми за допомогою цього зразка:
// 1. Define the distinct member types
interface Circle {
kind: 'circle'; // The discriminant
radius: number;
}
interface Square {
kind: 'square'; // The discriminant
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // The discriminant
width: number;
height: number;
}
// 2. Create the union type
type Shape = Circle | Square | Rectangle;
Тепер змінна типу `Shape` повинна бути одним із цих трьох інтерфейсів. Властивість `kind` діє як ключ, який відкриває можливості звуження типу TypeScript.
Реалізація перевірки вичерпності під час компіляції
Маючи наше розрізнене об'єднання, ми тепер можемо написати функцію, яка гарантовано компілятором обробляє кожну можливу форму. Магічний інгредієнт — це тип TypeScript `never`, який представляє значення, яке ніколи не повинно траплятися.
Ми можемо написати просту допоміжну функцію, щоб забезпечити це:
function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}
Тепер давайте перепишемо нашу функцію `calculateArea` за допомогою стандартного оператора `switch`. Подивіться, що станеться у випадку `default`:
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows `shape` is a Circle here!
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows `shape` is a Square here!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows `shape` is a Rectangle here!
return shape.width * shape.height;
default:
// If we've handled all cases, `shape` will be of type `never`
return assertUnreachable(shape);
}
}
Цей код компілюється ідеально. У кожному блоці `case` TypeScript звузив тип `shape` до `Circle`, `Square` або `Rectangle`, дозволяючи нам безпечно отримувати доступ до таких властивостей, як `radius`.
А тепер чарівний момент. Давайте впровадимо нову форму в нашу систему:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
type Shape = Circle | Square | Rectangle | Triangle; // Add it to the union
Щойно ми додамо `Triangle` до об’єднання `Shape`, наша функція `calculateArea` негайно видасть помилку під час компіляції:
// In the `default` block of `calculateArea`:
return assertUnreachable(shape);
// ~~~~~
// Argument of type 'Triangle' is not assignable to parameter of type 'never'.
Ця помилка неймовірно цінна. Компілятор TypeScript каже нам: «Ви обіцяли обробити кожну можливу `Shape`, але ви забули про `Triangle`. Змінна `shape` все ще може бути `Triangle` у випадку за замовчуванням, і це неможливо призначити `never`».
Щоб виправити помилку, ми просто додаємо відсутній випадок. Компілятор стає нашою мережею безпеки, гарантуючи, що наша логіка залишається синхронізованою з нашою моделлю даних.
// ... inside the switch
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
return assertUnreachable(shape);
// ... now the code compiles again!
Плюси та мінуси цього підходу
- Плюси:
- Нульові залежності: Він використовує лише основні функції TypeScript.
- Максимальна безпека типів: Забезпечує залізні гарантії під час компіляції.
- Чудова продуктивність: Він компілюється в оптимізований стандартний оператор `switch` JavaScript.
- Мінуси:
- Багатослівність: Шаблонний код `switch`, `case`, `break`/`return` і `default` може здатися громіздким.
- Не вираз: Оператор `switch` не можна безпосередньо повернути або призначити змінній, що призводить до більш імперативних стилів коду.
Техніка 2: Ергономічні API з сучасними бібліотеками
Хоча розрізнене об'єднання з оператором `switch` є основою, його шаблонний код може бути нудним. Це призвело до появи фантастичних бібліотек з відкритим кодом, які надають більш функціональний, виразний та ергономічний API для зіставлення зі зразком, все ще використовуючи компілятор TypeScript для безпеки.
Представляємо `ts-pattern`
Однією з найпопулярніших і потужних бібліотек у цій галузі є `ts-pattern`. Вона дозволяє замінити оператори `switch` на плавний, ланцюговий API, який працює як вираз.
Давайте перепишемо нашу функцію `calculateArea` за допомогою `ts-pattern`:
import { match } from 'ts-pattern';
function calculateAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (s) => Math.PI * s.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (s) => s.width * s.height)
.with({ kind: 'triangle' }, (s) => 0.5 * s.base * s.height)
.exhaustive(); // This is the key to compile-time safety
}
Давайте розберемо, що відбувається:
- `match(shape)`: Це запускає вираз зіставлення зі зразком, беручи значення для зіставлення.
- `.with({ kind: '...' }, handler)`: Кожен виклик `.with()` визначає зразок. `ts-pattern` достатньо розумний, щоб визначити тип другого аргументу (функцію `handler`). Для зразка `{ kind: 'circle' }` він знає, що вхідні дані `s` для обробника будуть типу `Circle`.
- `.exhaustive()`: Цей метод є еквівалентом нашого трюку `assertUnreachable`. Він повідомляє `ts-pattern`, що всі можливі випадки повинні бути оброблені. Якщо ми видалимо рядок `.with({ kind: 'triangle' }, ...)`, `ts-pattern` викличе помилку під час компіляції у виклику `.exhaustive()`, повідомляючи нам, що збіг не є вичерпним.
Розширені функції `ts-pattern`
`ts-pattern` виходить далеко за межі простого зіставлення властивостей:
- Зіставлення предикатів за допомогою `.when()`: Зіставлення на основі умови.
match(input) .when(isString, (str) => `It's a string: ${str}`) .when(isNumber, (num) => `It's a number: ${num}`) .otherwise(() => 'It is something else'); - Глибоко вкладені зразки: Зіставлення зі складними структурами об’єктів.
match(user) .with({ address: { city: 'Paris' } }, () => 'User is in Paris') .otherwise(() => 'User is elsewhere'); - Підстановчі знаки та спеціальні селектори: Використовуйте `P.select()` для захоплення значення в межах зразка або `P.string`, `P.number` для зіставлення будь-якого значення певного типу.
import { match, P } from 'ts-pattern'; match(event) .with({ type: 'USER_LOGIN', user: { name: P.select() } }, (name) => { console.log(`${name} logged in.`); }) .otherwise(() => {});
Використовуючи бібліотеку, таку як `ts-pattern`, ви отримуєте найкраще з обох світів: надійну безпеку під час компіляції перевірки `never` TypeScript у поєднанні з чистим, декларативним і дуже виразним API.
Майбутнє: Пропозиція TC39 щодо зіставлення зі зразком
Сама мова JavaScript знаходиться на шляху до отримання власного зіставлення зі зразком. Існує активна пропозиція в TC39 (комітет, який стандартизує JavaScript) щодо додавання виразу `match` до мови.
Запропонований синтаксис
Синтаксис, ймовірно, виглядатиме приблизно так:
// This is proposed JavaScript syntax and might change
const getMessage = (response) => {
return match (response) {
when ({ status: 200, body: b }) { return `Success with body: ${b}`; }
when ({ status: 404 }) { return 'Not Found'; }
when ({ status: s if s >= 500 }) { return `Server Error: ${s}`; }
default { return 'Unknown response'; }
}
};
Що щодо безпеки типів?
Це найважливіше питання для нашого обговорення. Сам по собі власний JavaScript зіставлення зі зразком виконуватиме свої перевірки під час виконання. Він не знатиме про ваші типи TypeScript.
Однак майже напевно команда TypeScript створить статичний аналіз поверх цього нового синтаксису. Подібно до того, як TypeScript аналізує оператори `if` і блоки `switch` для виконання звуження типу, він аналізуватиме вирази `match`. Це означає, що ми можемо зрештою отримати найкращий можливий результат:
- Власний, продуктивний синтаксис: Немає потреби в бібліотеках або трюках з транспіляцією.
- Повна безпека під час компіляції: TypeScript перевірятиме вираз `match` на вичерпність щодо розрізненого об’єднання, як це робить сьогодні для `switch`.
Поки ми чекаємо, коли ця функція пройде етапи пропозиції та потрапить у браузери та середовища виконання, техніки, які ми обговорювали сьогодні з розрізненими об’єднаннями та бібліотеками, є готовим до виробництва, сучасним рішенням.
Практичне застосування та найкращі практики
Давайте подивимось, як ці зразки застосовуються до звичайних сценаріїв розробки в реальному світі.
Керування станом (Redux, Zustand тощо)
Керування станом за допомогою дій є ідеальним випадком використання для розрізнених об’єднань. Замість використання рядкових констант для типів дій, визначте розрізнене об’єднання для всіх можливих дій.
// Define actions
interface IncrementAction { type: 'counter/increment'; payload: number; }
interface DecrementAction { type: 'counter/decrement'; payload: number; }
interface ResetAction { type: 'counter/reset'; }
type CounterAction = IncrementAction | DecrementAction | ResetAction;
// A type-safe reducer
function counterReducer(state: number, action: CounterAction): number {
return match(action)
.with({ type: 'counter/increment' }, (act) => state + act.payload)
.with({ type: 'counter/decrement' }, (act) => state - act.payload)
.with({ type: 'counter/reset' }, () => 0)
.exhaustive();
}
Тепер, якщо ви додасте нову дію до об’єднання `CounterAction`, TypeScript змусить вас оновити редуктор. Більше жодних забутих обробників дій!
Обробка відповідей API
Отримання даних з API передбачає кілька станів: завантаження, успіх і помилка. Моделювання цього за допомогою розрізненого об’єднання робить логіку вашого інтерфейсу користувача набагато надійнішою.
// Model the async data state
type RemoteData =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
// In your UI component (e.g., React)
function UserProfile({ userId }: { userId: string }) {
const [userState, setUserState] = useState>({ status: 'idle' });
// ... useEffect to fetch data and update state ...
return match(userState)
.with({ status: 'idle' }, () => Click a button to load the user.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (state) => )
.with({ status: 'error' }, (state) => )
.exhaustive();
}
Цей підхід гарантує, що ви реалізували інтерфейс користувача для кожного можливого стану отримання даних. Ви не можете випадково забути обробити випадок завантаження або помилки.
Підсумок найкращих практик
- Моделюйте за допомогою розрізнених об’єднань: Щоразу, коли у вас є значення, яке може бути однією з кількох різних форм, використовуйте розрізнене об’єднання. Це основа безпечних за типом зразків у TypeScript.
- Завжди забезпечуйте вичерпність: Незалежно від того, чи використовуєте ви трюк `never` з оператором `switch` чи метод `.exhaustive()` бібліотеки, ніколи не залишайте зіставлення зі зразком відкритим. Звідси походить безпека.
- Виберіть правильний інструмент: Для простих випадків підійде оператор `switch`. Для складної логіки, вкладеного зіставлення або більш функціонального стилю бібліотека, як-от `ts-pattern`, значно покращить читабельність і зменшить шаблонний код.
- Зберігайте зразки читабельними: Мета — ясність. Уникайте надмірно складних, вкладених зразків, які важко зрозуміти з першого погляду. Іноді розбиття зіставлення на менші функції є кращим підходом.
Висновок: Написання майбутнього безпечного JavaScript
Зіставлення зі зразком — це більше, ніж просто синтаксичний цукор; це парадигма, яка веде до більш декларативного, читабельного і, що найважливіше, більш надійного коду. Хоча ми з нетерпінням чекаємо його власної появи в JavaScript, нам не потрібно чекати, щоб скористатися його перевагами.
Використовуючи потужність статичної системи типів TypeScript, особливо з розрізненими об’єднаннями, ми можемо створювати системи, які можна перевірити під час компіляції. Цей підхід принципово переносить виявлення помилок з часу виконання на час розробки, заощаджуючи незліченну кількість годин налагодження та запобігаючи виробничим інцидентам. Бібліотеки, такі як `ts-pattern`, спираються на цю міцну основу, надаючи елегантний і потужний API, який робить написання безпечного за типом коду радістю.
Впровадження перевірки зразка під час компіляції — це крок до написання більш стійких і підтримуваних програм. Це спонукає вас явно думати про всі можливі стани, в яких можуть бути ваші дані, усуваючи неоднозначність і роблячи логіку вашого коду кришталево чистою. Почніть моделювати свій домен за допомогою розрізнених об’єднань сьогодні, і нехай компілятор TypeScript стане вашим невтомним партнером у створенні безпомилкового програмного забезпечення.