Введение
Эти заметки должны помочь лучше понять продвинутые темы TypeScript
и могут быть полезны при поиске информации о том, как использовать TypeScript в конкретной ситуации. Все примеры основаны на TypeScript 4.6.
Примечание: Эта заметка является обновленной версией оригинальной заметки о TypeScript: Обработка побочных эффектов
Базовый
При работе с TypeScript возникают ситуации, когда мы не можем гарантировать, что типы отражают реальные данные, с которыми мы работаем. Примерами таких ситуаций являются чтение из файла, получение данных из внешней конечной точки или загрузка информации, сохраненной в локальном хранилище. Во всех вышеперечисленных сценариях мы не можем гарантировать, что данные, поступающие в наше приложение, действительно отражают определенные нами типы. Более того, в любом из этих сценариев мы можем столкнуться с ошибками во время выполнения, независимо от того, что на самом деле утверждает тип.
Это означает, что если мы имеем дело с внешними данными, которые не определены во время компиляции, нам нужен какой-то механизм для безопасной работы с такими данными.
Чтобы сделать это более практичным, давайте предположим следующую ситуацию: мы хотим загрузить user
из заранее определенной конечной точки.
const loadUser = (id: number) => {
fetch(`http://www.your-defined-endpoint.com/users/${id}`)
.then((response) => response.json())
.then((user: User) => saveUser(user))
.catch((error) => {
console.log({ error });
});
};
На первый взгляд все это звучит разумно, мы получаем пользователя по id, а затем сохраняем данные для дальнейшей обработки. Если вы посмотрите на код внимательнее, то заметите, что после декодирования json-данных мы определили тип данных User
. Тип User
в этом примере определяется следующим образом:
type User = {
id: number;
name: string;
active: boolean;
profile: {
activatedAt: number;
};
};
Интересно, что код скомпилируется и TypeScript не покажет никаких ошибок, поскольку мы определили User
и заявили, что ответ, после декодирования, всегда будет вышеупомянутого типа. Еще более интересным является тот факт, что вызов функции json на объекте ответа возвращает Promise<any>
, поэтому нет фактической гарантии, что мы имеем дело с типом User
во время выполнения.
Рассмотрим сценарий, в котором наши предположения могут не сработать, поэтому добавим функцию saveUser
, которая ожидает пользователя с некоторой информацией о профиле:
const saveUser = (user: User) => {
const activationDate = user.profile.activatedAt;
// do something with the information...
};
Как теперь наше приложение может сломаться? Код выше будет компилироваться, но что произойдет, если возвращаемый объект пользователя не будет иметь никакой информации о профиле? Предположим, что во время выполнения мы вдруг получаем следующий объект:
{
id: 1,
name: "Some User Name",
active: true,
extended: {
activatedAt: 1640995200000
}
};
В результате внутри нашего приложения все еще будет User
, но мы столкнемся с ошибкой во время выполнения, как только вызовем функцию saveUser
. Один из способов решения этой проблемы – более оборонительный: расширить нашу функцию, чтобы проверить, существует ли вообще свойство profile
:
const saveUser = (user: User) => {
if (user && user.profile && user.profile.activatedAt) {
const activationDate = user.profile.activatedAt;
// do something with the information...
} else {
// do something else
}
};
Но это быстро станет сложным, когда нам придется делать такие проверки по всему приложению при работе с внешними данными. Скорее, мы хотим сделать эту проверку как можно раньше, фактически в тот момент, когда у нас есть доступ к этим данным.
Расширенный
TypeScript не предлагает никаких возможностей проверки JSON во время выполнения, но в экосистеме TypeScript есть библиотеки, которые мы можем использовать для этого конкретного случая.
Мы будем использовать популярную библиотеку io-ts
, чтобы обеспечить надежность данных, с которыми мы работаем, на протяжении всего приложения. Наш подход будет заключаться в декодировании любых внешних данных, поступающих в наше приложение.
Библиотека io-ts
написана Джулио Канти и предлагает проверку типов во время выполнения. Для получения дополнительной информации о io-ts обратитесь к README
. Так называемые кодеки
используются для кодирования/декодирования данных. Эти кодеки являются представлениями определенных статических типов во время выполнения и могут быть составлены для построения еще больших валидаторов типов.
Кодеки позволяют нам кодировать и декодировать любые входящие и исходящие данные, а встроенный метод decode
возвращает тип Either
, который представляет успех (Right) и неудачу (Left). Используя эту функциональность, мы можем декодировать внешние данные и обрабатывать случаи успеха/неудачи. Для лучшего понимания давайте восстановим наш предыдущий пример, используя библиотеку io-ts
.
import * as t from "io-ts";
const User = t.type({
id: t.number,
name: t.string,
active: t.boolean,
profile: t.type({
activatedAt: t.number,
}),
});
Комбинируя различные кодеки, такие как string
или number
, мы можем построить тип исполнения User
, который мы можем использовать для проверки любых входящих данных user
.
Предыдущая базовая конструкция имеет ту же форму, что и тип User
, который мы определили ранее. Однако мы не хотим переопределять User
как статический тип. Здесь нам может помочь io-ts
, предложив TypeOf
, который позволяет земле пользователя генерировать статическое представление построенного User
.
type UserType = t.TypeOf<typeof User>;
Интересно, что это даст нам то же представление, которое мы определили в начале:
type UserType = {
id: number,
name: string,
active: boolean,
profile: {
activatedAt: number,
},
};
После того, как мы определили форму, мы можем проверить, имеют ли данные ожидаемую форму, и обработать успешный или неуспешный случай:
const userA = {
id: 1,
name: "Test User A",
active: true,
profile: {
activatedAt: t.number,
},
};
const result = User.decode(userA);
if (result._tag === "Right") {
// handle the success case
// access the data
result.right;
} else {
// handle the failure
}
Результат функции декодирования содержит свойство _tag
, которое может быть либо строкой Right
, либо Left
, что означает успех или неудачу. Кроме того, мы имеем доступ к свойствам right
и left
, содержащим декодированные данные в случае успеха (справа) или сообщение об ошибке в случае неудачи (справа).
Приведенный выше пример может быть расширен для использования так называемого PathReporter
для обработки сообщений об ошибках:
import { PathReporter } from "io-ts/lib/PathReporter";
if (result._tag === "Right") {
// handle the success case
// access the data
result.right;
} else {
// handle the failure
console.warn(PathReporter.report(result).join("n"));
}
io-ts
также поставляется с fp-ts
в качестве зависимой части, которая предлагает такие полезные функции, как isRight
или fold
. Мы можем использовать функцию isRight
для проверки правильности результата декодирования, вместо того, чтобы вручную обрабатывать его через свойство _tag
.
import * as t from "io-ts";
import { isRight } from "fp-ts/lib/Either";
const userA = {
id: 1,
name: "Test User A",
active: true,
profile: {
activatedAt: t.number,
},
};
isRight(User.decode(userA)); // true
const userB = {
id: 1,
name: "Test User",
active: true,
extended: {
activatedAt: t.number,
},
};
isRight(User.decode(userB)); // false
Еще одна полезная функция, которая поможет нам при работе с типом Either
, который возвращает декодирование, это fold
, которая позволяет нам определить путь успеха и неудачи, смотрите следующий пример для более подробного объяснения:
const validate = fold(
(error) => console.log({ error }),
(result) => console.log({ result })
);
// success case
validate(User.decode(userA));
// failure case
validate(User.decode(userB));
Использование fold
позволяет нам обрабатывать корректные или некорректные данные при вызове нашей функциональности fetch. Функция loadUser
теперь может быть рефакторингована для обработки этих случаев.
const resolveUser = fold(
(errors: t.Errors) => {
throw new Error(`${errors.length} errors found!`);
},
(user: User) => saveUser(user)
);
const loadUser = (id: number) => {
fetch(`http://www.your-defined-endpoint.com/users/${id}`)
.then((response) => response.json())
.then((user) => resolveUser(User.decode(user)))
.catch((error) => {
console.log({ error });
});
};
Мы можем обработать любое неправильное представление, выбросив еще одну ошибку. Это предотвращает передачу данных в нашем приложении. Здесь можно сделать еще больше улучшений. Сейчас мы очень специфичны в том, как мы обрабатываем декодирование User
. Возможно, есть возможность написать общую функцию, которая обрабатывает любые данные, основанные на обещаниях.
const decodePromise = <I, O>(type: t.Decoder<I, O>, value: I): Promise<O> => {
return (
fold < t.Errors,
O,
Promise <
O >>
((errors) => Promise.reject(errors),
(result) => Promise.resolve(result))(type.decode(value))
);
};
Наша функция decodePromise
обрабатывает любые входные данные на основе определенного декодера, а затем возвращает обещание, основанное на выполнении фактической операции декодирования.
const loadUser = (id: number) => {
fetch(`http://www.your-defined-endpoint.com/users/${id}`)
.then((response) => response.json())
.then((user) => decodePromise(User, user))
.then((user: User) => state.saveUser(user))
.catch((error) => {
console.log({ error });
});
};
Можно было бы сделать еще больше улучшений, но мы должны иметь базовое понимание того, почему может быть полезно проверять любые внешние данные во время выполнения. io-ts
предлагает больше возможностей для работы с рекурсивными и опциональными типами. Кроме того, существуют библиотеки типа io-ts-promise
, которые предоставляют больше возможностей и полезных помощников, например, вышеупомянутый decodePromise
доступен в более продвинутом варианте через io-ts-promise
.
Ссылки
io-ts
io-ts-promise
Если у вас есть вопросы или отзывы, пожалуйста, оставьте комментарий здесь или свяжитесь с нами через Twitter: А. Шариф