TypeScript
March 28

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

Это вольный перевод материала