Обработка ошибок в TypeScript без try/catch

Начну с небольшого рассказа о себе. Я разработчик программного обеспечения с 10-летним стажем. Начинал работать с PHP, затем постепенно перешел на JavaScript.

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

Постепенно я начал знакомиться с другими языками, более современными. Первым в списке моих фаворитов оказался Go, затем к нему добавился Rust.

Трудно не заметить их достоинства при сравнении с другими языками.

Что я имею в виду? То общее, что есть у Go и Rust. Ошибки. Именно они прежде всего привлекли мое внимание. А точнее, то, как эти языки с ними справляются.

Если в JavaScript используются исключения для обработки ошибок, то в Go и Rust они рассматриваются как значения. Можно подумать, что это не так уж и важно, но это меняет правила игры.

Посмотрим, как реализуется обработка ошибок в различных языках. Не будем подробно останавливаться на каждом  —  важно понять общий подход.

Начнем с JavaScript/TypeScript.

Просмотрите приведенный ниже код (для этого понадобится секунд пять) и ответьте на вопрос: почему необходимо обернуть его в try/catch?

try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}
// обработка ответа
} catch (e) {
// обработка ошибки
return;
}

Полагаю, многие из вас догадались, что, несмотря на проверку response.ok, метод fetch может выдать ошибку. response.ok “ловит” только сетевые ошибки 4xx и 5xx. Но ошибка может быть выдана при сбое самой сети.

А интересно, догадались ли вы, что JSON.stringify тоже выдаст ошибку? Дело в том, что объект запроса содержит переменную bigint (2n), которую JSON не умеет преобразовывать в строку.

Итак, проблема первая (на мой взгляд, самая большая в JavaScript): мы не знаем, что может привести к ошибке. С точки зрения ошибок в JavaScript, это то же самое, что:

try {
let data = “Hello”;
} catch (err) {
console.error(err);
}

JavaScript не знает, ему все равно. Вы должны знать.

Перейдем ко второй проблеме. Вот вполне жизнеспособный код:

const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
return;
}

Никаких ошибок, никаких линтеров, и все же он может вывести из строя приложение.

Так почему бы не использовать try/catch везде? И тут возникает третья проблема: как узнать, где именно? Конечно, можно как-то догадаться по сообщению об ошибке, но как быть с большими сервисами/функциями, содержащими множество мест, где могут возникнуть ошибки? Уверены, что правильно обработаете их все с помощью одного только try/catch?

Хватит критиковать JS  —  будем двигаться дальше. Перейдем к коду, написанному на языке Go:

f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// какие-либо действия с открытым *File f

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

Код проверки ошибок (“error-checking code”), где if err != nil { …. иногда занимает больше строк кода, чем все остальное, выглядит не очень красиво, и это одна из причин, по которой критикуют Go.

if err != nil {

if err != nil {

if err != nil {

}
}
}
if err != nil {

}

if err != nil {

}

И все же оно того стоит.

И наконец, Rust:

let greeting_file_result = File::open(“hello.txt”);
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};

Rust  —  самый подробный из трех представленных здесь языков и, как ни странно, лучший. Прежде всего потому, что он обрабатывает ошибки с помощью перечислений (это не то же самое, что перечисления TypeScript). Не вдаваясь в детали, можно сказать, что здесь используется перечисление Result с двумя вариантами: Ok и Err. Как вы уже догадались, Ok содержит значение, а Err  —  ошибку.

Минимизируя проблемы обработки ошибок в Go, Rust предлагает множество способов более удобной работы с ними. Наиболее широко используемый из них  —  оператор ?.

let greeting_file_result = File::open(“hello.txt”)?;

Суть в том, что и Go, и Rust всегда знают, где может возникнуть ошибка. И они заставляют вас бороться с ней прямо там, где она появляется (в большинстве случаев). Никаких скрытых ошибок, никаких догадок, никаких непредвиденных сбоев в работе приложений.

И этот подход лучше других.

Теперь пришло время признаться в том, что я немного слукавил. Нельзя заставить TypeScript работать с ошибками так же, как Go/Rust. Ограничивающим фактором здесь является сам язык, у него нет соответствующих инструментов для этого.

Но в наших силах попытаться сделать TypeScript похожим на Go/Rust. И сделать это довольно легко.

Начнем с этого:

export type Safe<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: string;
};

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

Признаюсь, что приукрасил еще раз, когда сказал, что нам не понадобятся несколько try/catch. Все же понадобятся. Правда, всего пару раз, а не 100 000.

export function safe<T>(promise: Promise<T>, err?: string): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: string): Safe<T>;
export function safe<T>(
promiseOrFunc: Promise<T> | (() => T),
err?: string,
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}

async function safeAsync<T>(
promise: Promise<T>,
err?: string
): Promise<Safe<T>> {
try {
const data = await promise;
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}

function safeSync<T>(
func: () => T,
err?: string
): Safe<T> {
try {
const data = func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: "Something went wrong" };
}
}

Это просто обертка с типом Safe в качестве возвращаемого типа. Решение простое, как и все гениальное. Применим его к примеру, приведенному выше.

Прежний код (16 строк):

try {
const request = { name: “test”, value: 2n };
const body = JSON.stringify(request);
const response = await fetch("https://example.com", {
method: “POST”,
body,
});
if (!response.ok) {
// обработка сетевой ошибки
return;
}
// обработка ответа
} catch (e) {
// обработка ошибки
return;
}

Новый код (20 строк):

const request = { name: “test”, value: 2n };
const body = safe(
() => JSON.stringify(request),
“Failed to serialize request”,
);
if (!body.success) {
// обработка ошибки (body.error)
return;
}
const response = await safe(
fetch("https://example.com", {
method: “POST”,
body: body.data,
}),
);
if (!response.success) {
// обработка ошибки (response.error)
return;
}
if (!response.data.ok) {
// обработка сетевой ошибки
return;
}
// обработка ответа (body.data)

Итак, новое решение несколько длиннее, но оно работает лучше по следующим причинам:

  • нет try/catch;
  • каждая ошибка обрабатывается там, где возникает;
  • вы можете указать сообщение об ошибке для конкретной функции;
  • достигается красивая логика “сверху вниз”: все ошибки сверху, затем только ответ снизу.

А теперь главный козырь. Посмотрим, что произойдет, если не выполнить эту проверку:

if (!body.success) {
// обработка ошибки (body.error)
return;
}

Дело в том, что такого быть не может. Да, вы обязательно выполните эту проверку. Если этого не сделать, то body.data не будет. LSP напомнит вам об этом, выбросив ошибку Property ‘data’ does not exist on type ‘Safe<string>’. И все это благодаря простому типу Safe. Данный шаблон работает и для сообщений об ошибках. Вы не получите доступа к body.error до тех пор, пока не проверите !body.success.

Это тот случай, когда следует по достоинству оценить TypeScript и то, как он изменил мир JavaScript.

То же самое касается следующего:

if (!response.success) {
// обработка ошибки (response.error)
return;
}

Вы не можете избежать проверки !response.success, иначе не будет response.data.

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

Но это не так сложно, как может показаться. Вскоре вы начнете понимать, что почти все промисы, имеющиеся в коде, могут выбрасывать ошибки. А синхронные функции, которые могут выдавать ошибки, вам известны, и их не так много.

И все же стоит ли оно того? Наша команда считает, что стоит, поскольку у нас это отлично работает. Глаз радуется, когда видишь большой служебный файл, где нет ни одного try/catch, где каждая ошибка обрабатывается в месте ее возникновения, где весь поток подчинен логике.

Вот реальный пример использования FormAction в SvelteKit:

export const actions = {
createEmail: async ({ locals, request }) => {
const end = perf(“CreateEmail”);
const form = await safe(request.formData());
if (!form.success) {
return fail(400, { error: form.error });
}
const schema = z
.object({
emailTo: z.string().email(),
emailName: z.string().min(1),
emailSubject: z.string().min(1),
emailHtml: z.string().min(1),
})
.safeParse({
emailTo: form.data.get("emailTo"),
emailName: form.data.get("emailName"),
emailSubject: form.data.get("emailSubject"),
emailHtml: form.data.get("emailHtml"),
});
if (!schema.success) {
console.error(schema.error.flatten());
return fail(400, { form: schema.error.flatten().fieldErrors });
}
const metadata = createMetadata(URI_GRPC, locals.user.key)
if (!metadata.success) {
return fail(400, { error: metadata.error });
}
const response = await new Promise<Safe<Email__Output>>((res) => {
usersClient.createEmail(schema.data, metadata.data, grpcSafe(res));
});
if (!response.success) {
return fail(400, { error: response.error });
}
end();
return {
email: response.data,
};
},
} satisfies Actions;

Несколько моментов, на которые следует обратить внимание:

  • пользовательская функция grpcSafe поможет с обратным вызовом gGRPC;
  • createMetadata возвращает Safe внутри, поэтому нам не нужно ее оборачивать;
  • в библиотеке zod используется та же схема  —  без выполнения проверки schema.success нет доступа к schema.data.

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

Напоследок сравните вот это:

f, err := os.Open(“filename.ext”)
if err != nil {
log.Fatal(err)
}
// какие-либо действия с открытым *File f
const response = await safe(fetch(“https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// какие-либо действия с response.data

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Mateusz Piorowski: TypeScript With Go and Rust Errors? No Try/Catch? Heresy

Предыдущая статьяКак интегрировать Kafka со Spring Boot
Следующая статьяПодробный разбор фреймворка Observation. Новый способ повысить производительность SwiftUI