TypeScript с ошибками Go/Rust. Не используем try/catch
Итак, начнем с небольшой предыстории обо мне. Я разработчик программного обеспечения с 10-летним опытом работы, сначала работаю с PHP, а затем постепенно перехожу на JavaScript. Кроме того, это моя первая статья, поэтому, пожалуйста, поймите.
Я начал использовать TypeScript где-то около 5 лет назад и с тех пор больше не возвращался к JavaScript. В тот момент мне казалось, что это ЛУЧШИЙ язык программирования из когда-либо созданных. Всем он нравится и многие его используют.
Да, а потом я начал играть с другими языками, более современными. Сначала был Go, а потом я постепенно добавил в свой список Rust (спасибо Prime).
Трудно что-то упустить, когда не знаешь, что разные вещи существуют.
О чем я говорю? Что общего у Go и Rust? ОШИБКИ. Единственное, что больше всего запомнилось мне. А точнее, как эти языки с ними справляются.
JavaScript полагается на создание исключений для обработки ошибок, тогда как Go и Rust рассматривают их как значения. Вы можете подумать, что это не так уж и важно... но, мальчик, это может показаться тривиальной вещью; однако это меняет правила игры.
Пройдемся по ним. Мы не будем углубляться в каждый язык; мы просто хотим знать общий подход.
Начнем с JavaScript/TypeScript и небольшой игры.
Дайте себе 5 секунд, чтобы посмотреть на код ниже и ответить, ПОЧЕМУ нам нужно обернуть его в 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;
}
// handle response
} catch (e) {
// handle error
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 не знает, 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». А вот и третья проблема; мы не знаем, КАКОЙ бросок. Конечно, мы можем как-то догадаться по сообщению об ошибке, но для более крупных сервисов/функций, с большим количеством мест, где может произойти ошибка? Вы уверены, что правильно обрабатываете их все за один раз?
Ладно, пора перестать придираться к JS и перейти к чему-то другому. Начнем с Go:
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
Мы пытаемся открыть файл, который возвращает либо файл, либо ошибку. И вы будете часто сталкиваться с этим, в основном потому, что мы знаем, какие функции всегда возвращают ошибки. Вы никогда не пропустите ни одного. Вот первый пример обработки ошибки как значения. Вы указываете, какая функция может их вернуть, вы возвращаете их, вы назначаете их, вы проверяете их, вы работаете с ними.
Это также не так красочно, и это также одна из вещей, за которые Go критикуют, error-checking code
, где if err != nil { ....
иногда занимает больше строк кода, чем остальные.
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!). Не вдаваясь в подробности, здесь важно то, что он использует Enum с именем Result
с двумя вариантами: Ok
и Err
. Как вы могли догадаться, Ok
содержит значение, а Err
содержит... сюрприз, ошибку :D.
У него также есть много способов справиться с ними более удобными способами, чтобы смягчить проблему Go. Самый известный из них ?
оператор.
let greeting_file_result = File::open("hello.txt")?;
Подводя итог, можно сказать, что и 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" };
}
}
«Вау, какой гений, он создал обертку для try/catch». Да, ты прав; это просто обертка с нашим типом 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) {
// handle network error
return;
}
// handle response
} catch (e) {
// handle error
return;
}
Новый (20 строк):
const request = { name: "test", value: 2n };
const body = safe(
() => JSON.stringify(request),
"Failed to serialize request",
);
if (!body.success) {
// handle error (body.error)
return;
}
const response = await safe(
fetch("https://example.com", {
method: "POST",
body: body.data,
}),
);
if (!response.success) {
// handle error (response.error)
return;
}
if (!response.data.ok) {
// handle network error
return;
}
// handle response (body.data)
Так что да, наше новое решение длиннее, но:
- отсутствует try-catch
- мы обрабатываем каждую ошибку там, где она возникает
- мы можем указать сообщение об ошибке для конкретной функции
- У нас хорошая логика сверху вниз, все ошибки сверху. Внизу расположен только ответ
Пришло время для главного. Что произойдет, если мы забудем проверить это:
if (!body.success) {
// handle error (body.error)
return;
}
Дело в том, что мы не сможем. Да, мы ДОЛЖНЫ сделать эту проверку; если мы этого не сделаем, то body.data
не будет существовать. LSP напомнит нам об этом, выдав «Свойство 'data' не существует для типа 'Safe'». И все благодаря созданному нами простому типу Safe
. И это также работает для сообщения об ошибке; у нас нет доступа к body.error
, пока мы не проверим !body.success
.
Вот момент, когда мы должны по-настоящему оценить TypeScript и то, как он изменил мир JavaScript.
То же самое касается:
if (!response.success) {
// handle error (response.error)
return;
}
Мы не можем удалить проверку !response.success
, потому что иначе response.data
не будет существовать.
Конечно, наше решение не обходится без проблем, самая большая из которых заключается в том, что вам нужно помнить о том, чтобы оборачивать промисы/функции, которые могут вызывать ошибки, в нашу безопасную оболочку. Это «нам нужно знать» является языковым ограничением, которое мы не можем преодолеть.
Это может звучать тяжело, но это не так. Вскоре вы начинаете понимать, что почти все промисы, которые есть у вас в коде, могут выдавать ошибки, а синхронные функции, которые могут, вы о них знаете, и их не так много.
Тем не менее, вы можете спросить, а стоит ли? Мы думаем, что да, и в нашей команде это отлично работает :). Когда вы смотрите на более крупный служебный файл, где нет try/catch, где каждая ошибка обрабатывается там, где она появилась, с хорошей логикой... это просто выглядит красиво.
Вот пример использования в реальной жизни (SvelteKit FormAction):
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
, чтобы помочь нам с обратным вызовом grpc - createMetadata возвращает
Safe
внутри, поэтому нам не нужно его оборачивать - библиотека
zod
использует тот же шаблон. Если мы не сделаемschema.succes
, проверьте, у нас нет доступа кschema.data
.
Разве это не выглядит чистым? Так что попробуйте! Возможно, вам он тоже подойдет
Кроме того, я надеюсь, что эта статья была интересна для вас. Я надеюсь создать больше из них, чтобы поделиться своими мыслями и идеями.
P.S. Посмотрите сама и сравните?
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open *File f
const response = await safe(fetch("https://example.com"));
if (!response.success) {
console.error(response.error);
return;
}
// do something with the response.data