Обработка ошибок с использованием типа never в TypeScript
Тип never в TypeScript используется для представления пустого набора значений. Он указывает на ситуацию, которая никогда не должна происходить. По сути, never является нижним типом. Он исчезает из-за теории множеств: если добавить пустой набор к другому набору, то останется только второй набор. Проще говоря, never "поглощается" другими типами, и его использование для представления выброшенных ошибок не рекомендуется.
Существуют ситуации, когда можно смоделировать невозможные состояния с помощью этого типа.
Во-первых, это можно сделать через использование дискриминирующих объединений и проверок на полноту. Представьте себе выражение моделей как объединение типов.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Square | Rectangle;Обратите внимание, что kind имеет определенное строковое значение (литерал). Это дискриминирующее объединение. Обычно при создании объединения типов TypeScript разрешает элементы, которые попадают в пересекающиеся области множеств, что означает, что объект с { radius: 3, side: 4, width: 5 } будет принят как Shape.
Но, используя литеральный тип, TypeScript может различать разные типы и разрешать только правильные свойства для каждого типа. Это потому, что "circle | square | rectangle не имеют пересечений.
С помощью этого дискриминирующего объединения мы теперь можем использовать проверки на полноту, чтобы убедиться, что мы обрабатываем все случаи.
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.side ** 2;
case "rectangle":
return s.width * s.height;
}
}Мы еще не обработали случай по умолчанию, но мы можем использовать never , чтобы указать, что этот случай никогда не должен происходить.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.side ** 2;
case "rectangle":
return s.width * s.height;
default:
return assertNever(s);
}
}Это интересно. У нас есть случай по умолчанию, который никогда не должен произойти. Изменим тип Shape, добавив тип Triangle. При этом обработку внутри функции оставим без изменений.
type Triangle = { kind: "triangle"; a: number; b: number; c: number };
type Shape = Circle | Square | Rectangle | Triangle;
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "square":
return s.side ** 2;
case "rectangle":
return s.width * s.height;
default:
return assertNever(s);
// ~
// Argument of type 'Triangle' is not assignable
// to parameter of type 'never'.
}
}
TypeScript поймёт, что не все варианты были обработаны и подсветит ошибку в IDE.
Типы ошибок
Есть подход наследованный от языка Rust, когда разработчик может использовать тип результата, чтобы отобразить возможность ошибки в функции. В данном случае придется поработать с объединениями типов. Итак:
1. Определим тип Error, который несет сообщение об ошибке и имеет свойство kind, установленное на error.
2. Определим тип Success, в котором kind установлен как success.
3. Объединим созданные ранее типы в Result
4. Создадим 2 функции-конструктора для создания значений типов Error и Success
type ErrorT = { kind: "error"; error: string };
type Success<T> = { kind: "success"; value: T };
type Result<T> = ErrorT | Success<T>;
function error(msg: string): ErrorT {
return { kind: "error", error: msg };
}
function success<T>(value: T): Success<T> {
return { kind: "success", value };
}Создадим функцию divideи вернем значения используя описанные выше хелперы success и error
function divide(a: number, b: number): Result<number> {
if (b === 0) {
return error("Division by zero");
}
return success(a / b);
}Далее, если понадобится получить результат деления - нужно проверить результат выполнения, который хранится в kind и обработать соответствующий случай.
const result = divide(10, 0);
if (result.kind === "error") {
// result is of type Error
console.error(result.error);
} else {
// result is of type Success<number>
console.log(result.value);
}Важно, что типы верны, и система типов знает о всех возможных состояниях. Такой подход можно развить дальше, создав функцию safe, которая принимает исходную функцию и её аргументы, и упаковывает всё в новую систему обработки ошибок:
function safe<Args extends unknown[], R>(
fn: (...args: Args) => R,
...args: Args
): Result<R> {
try {
return success(fn(...args));
} catch (e: any) {
return error("Error: " + e?.message ?? "unknown");
}
}
function unsafeDivide(a: number, b: number): number {
if (b == 0) {
throw new Error("Division by Zero!");
}
return a / b;
}
const result = safe(unsafeDivide, 10, 0);Или в случае наличия результата можно вызвать функцию ошибки:
function fail<T>(fn: () => Result<T>): T {
const result = fn();
if (result.kind === "success") {
return result.value;
}
throw new Error(result.error);
}
const a = fail(divide(10, 0));
Такими костылями можно заставить Typescript четко проверять типы. Вряд ли это применимо в каком-либо реальном проекте.
Это вольный перевод материала