Узнайте, как добиться типобезопасного, проверенного во время компиляции сопоставления с образцом в JavaScript, используя TypeScript, размеченные объединения и современные библиотеки.
Сопоставление с образцом и безопасность типов в JavaScript: руководство по проверке во время компиляции
Сопоставление с образцом — одна из самых мощных и выразительных функций в современном программировании, давно известная в функциональных языках, таких как Haskell, Rust и F#. Она позволяет разработчикам деконструировать данные и выполнять код на основе их структуры лаконичным и невероятно читаемым способом. Поскольку JavaScript продолжает развиваться, разработчики все чаще стремятся применять эти мощные парадигмы. Однако остается серьезная проблема: как добиться надежной безопасности типов и гарантий времени компиляции этих языков в динамичном мире JavaScript?
Ответ заключается в использовании статической системы типов TypeScript. Хотя сам JavaScript постепенно приближается к собственному сопоставлению с образцом, его динамическая природа означает, что любые проверки будут происходить во время выполнения, что потенциально может привести к неожиданным ошибкам в production. Эта статья представляет собой углубленное изучение методов и инструментов, которые обеспечивают истинную проверку шаблонов во время компиляции, гарантируя, что вы обнаружите ошибки не тогда, когда это сделают ваши пользователи, а когда вы печатаете.
Мы рассмотрим, как создавать надежные, самодокументируемые и устойчивые к ошибкам системы, сочетая мощные возможности TypeScript с элегантностью сопоставления с образцом. Приготовьтесь устранить целый класс ошибок времени выполнения и писать код, который безопаснее и проще в обслуживании.
Что такое сопоставление с образцом?
По своей сути сопоставление с образцом — это сложный механизм управления потоком. Это как `switch` statement на стероидах. Вместо того чтобы просто проверять равенство простым значениям (например, числам или строкам), сопоставление с образцом позволяет проверять значение на соответствие сложным «образцам» и, если совпадение найдено, привязывать переменные к частям этого значения.
Давайте сравним это с традиционными подходами:
Старый способ: цепочки `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`, используя стандартный statement `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.
- Максимальная безопасность типов: Обеспечивает надежные гарантии во время компиляции.
- Отличная производительность: Он компилируется в высокооптимизированный стандартный statement `switch` JavaScript.
- Минусы:
- Многословие: statement `switch`, `case`, `break`/`return` и `default` могут показаться громоздкими.
- Не выражение: statement `switch` нельзя напрямую вернуть или присвоить переменной, что приводит к более императивным стилям кода.
Метод 2: Эргономичные API с современными библиотеками
Хотя размеченное объединение со statement `switch` является основой, его шаблон может быть утомительным. Это привело к появлению фантастических библиотек с открытым исходным кодом, которые предоставляют более функциональный, выразительный и эргономичный API для сопоставления с образцом, при этом все еще используя компилятор TypeScript для обеспечения безопасности.
Представляем `ts-pattern`
Одной из самых популярных и мощных библиотек в этой области является `ts-pattern`. Она позволяет заменять statement `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 анализирует statement `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` с statement `switch` или метод `.exhaustive()` библиотеки, никогда не оставляйте сопоставление с образцом открытым. Отсюда и происходит безопасность.
- Выберите правильный инструмент: В простых случаях statement `switch` вполне подходит. Для сложной логики, вложенного сопоставления или более функционального стиля библиотека, такая как `ts-pattern`, значительно улучшит читаемость и уменьшит количество шаблонов.
- Сделайте шаблоны читаемыми: Цель — ясность. Избегайте чрезмерно сложных, вложенных шаблонов, которые трудно понять с первого взгляда. Иногда лучше разбить сопоставление на более мелкие функции.
Заключение: написание будущего безопасного JavaScript
Сопоставление с образцом — это больше, чем просто синтаксический сахар; это парадигма, которая приводит к более декларативному, читаемому и — самое главное — более надежному коду. Пока мы с нетерпением ждем его собственного появления в JavaScript, нам не нужно ждать, чтобы пожинать его плоды.
Используя мощь статической системы типов TypeScript, особенно с размеченными объединениями, мы можем создавать системы, которые можно проверить во время компиляции. Этот подход коренным образом переносит обнаружение ошибок со времени выполнения на время разработки, экономя бесчисленные часы отладки и предотвращая производственные инциденты. Библиотеки, такие как `ts-pattern`, строятся на этой прочной основе, предоставляя элегантный и мощный API, который делает написание типобезопасного кода радостью.
Принятие проверки шаблонов во время компиляции — это шаг к написанию более отказоустойчивых и удобных в обслуживании приложений. Это побуждает вас явно думать обо всех возможных состояниях, в которых могут находиться ваши данные, устраняя неоднозначность и делая логику вашего кода кристально чистой. Начните моделировать свой домен с помощью размеченных объединений сегодня, и пусть компилятор TypeScript станет вашим неутомимым партнером в создании безошибочного программного обеспечения.