Обработка ошибок с использованием типа 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 четко проверять типы. Вряд ли это применимо в каком-либо реальном проекте.
Это вольный перевод материала